@scrymore/scry-deployer 0.0.4 โ 0.0.6
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 +1 -0
- package/bin/cli.js +162 -15
- package/lib/apiClient.js +139 -64
- package/lib/config.js +54 -26
- package/lib/coverage.js +188 -0
- package/lib/logger.js +16 -0
- package/lib/pr-comment.js +136 -0
- package/lib/templates.js +20 -60
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -50,6 +50,7 @@ This tool is designed for execution within a CI/CD pipeline (such as GitHub Acti
|
|
|
50
50
|
- ๐ Auto-detection of `.stories.*` files
|
|
51
51
|
- ๐ Story metadata extraction and analysis
|
|
52
52
|
- ๐ธ Automated screenshot capture with storycap
|
|
53
|
+
- ๐งช Storybook coverage analysis + PR summary comments (see `docs/COVERAGE.md`)
|
|
53
54
|
- ๐ฆ Organized master ZIP packaging (staticsite, images, metadata)
|
|
54
55
|
- โ๏ธ Flexible configuration (CLI, env vars, config file)
|
|
55
56
|
- ๐ Secure presigned URL uploads
|
package/bin/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
const Sentry = require('@sentry/node');
|
|
3
4
|
const yargs = require('yargs/yargs');
|
|
4
5
|
const { hideBin } = require('yargs/helpers');
|
|
5
6
|
const fs = require('fs');
|
|
@@ -7,12 +8,14 @@ const path = require('path');
|
|
|
7
8
|
const os = require('os');
|
|
8
9
|
const { zipDirectory } = require('../lib/archive.js');
|
|
9
10
|
const { createMasterZip } = require('../lib/archiveUtils.js');
|
|
10
|
-
const { getApiClient,
|
|
11
|
+
const { getApiClient, uploadBuild } = require('../lib/apiClient.js');
|
|
11
12
|
const { createLogger } = require('../lib/logger.js');
|
|
12
13
|
const { AppError, ApiError } = require('../lib/errors.js');
|
|
13
14
|
const { loadConfig } = require('../lib/config.js');
|
|
14
15
|
const { captureScreenshots } = require('../lib/screencap.js');
|
|
15
16
|
const { analyzeStorybook } = require('../lib/analysis.js');
|
|
17
|
+
const { runCoverageAnalysis, loadCoverageReport, extractCoverageSummary } = require('../lib/coverage.js');
|
|
18
|
+
const { postPRComment } = require('../lib/pr-comment.js');
|
|
16
19
|
const { runInit } = require('../lib/init.js');
|
|
17
20
|
|
|
18
21
|
async function runAnalysis(argv) {
|
|
@@ -83,6 +86,8 @@ async function runDeployment(argv) {
|
|
|
83
86
|
const outPath = path.join(os.tmpdir(), `storybook-deployment-${Date.now()}.zip`);
|
|
84
87
|
|
|
85
88
|
try {
|
|
89
|
+
const { coverageReport, coverageSummary } = await resolveCoverage(argv, logger);
|
|
90
|
+
|
|
86
91
|
if (argv.withAnalysis) {
|
|
87
92
|
// Full deployment with analysis
|
|
88
93
|
logger.info('Running deployment with analysis...');
|
|
@@ -117,16 +122,22 @@ async function runDeployment(argv) {
|
|
|
117
122
|
logger.success(`โ
Master archive created: ${outPath}`);
|
|
118
123
|
logger.debug(`Archive size: ${fs.statSync(outPath).size} bytes`);
|
|
119
124
|
|
|
120
|
-
// 4. Upload archive
|
|
125
|
+
// 4. Upload archive (+ optional coverage)
|
|
121
126
|
logger.info('4/5: Uploading to deployment service...');
|
|
122
127
|
const apiClient = getApiClient(argv.apiUrl, argv.apiKey);
|
|
123
|
-
const uploadResult = await
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
const uploadResult = await uploadBuild(
|
|
129
|
+
apiClient,
|
|
130
|
+
{
|
|
131
|
+
project: argv.project,
|
|
132
|
+
version: argv.version,
|
|
133
|
+
},
|
|
134
|
+
{ zipPath: outPath, coverageReport }
|
|
135
|
+
);
|
|
127
136
|
logger.success('โ
Archive uploaded.');
|
|
128
137
|
logger.debug(`Upload result: ${JSON.stringify(uploadResult)}`);
|
|
129
138
|
|
|
139
|
+
await postPRComment(buildDeployResult(argv, coverageSummary), coverageSummary);
|
|
140
|
+
|
|
130
141
|
logger.success('\n๐ Deployment with analysis successful! ๐');
|
|
131
142
|
|
|
132
143
|
} else {
|
|
@@ -137,16 +148,22 @@ async function runDeployment(argv) {
|
|
|
137
148
|
logger.success(`โ
Archive created: ${outPath}`);
|
|
138
149
|
logger.debug(`Archive size: ${fs.statSync(outPath).size} bytes`);
|
|
139
150
|
|
|
140
|
-
// 2. Authenticate and upload directly
|
|
151
|
+
// 2. Authenticate and upload directly (+ optional coverage)
|
|
141
152
|
logger.info('2/3: Uploading to deployment service...');
|
|
142
153
|
const apiClient = getApiClient(argv.apiUrl, argv.apiKey);
|
|
143
|
-
const uploadResult = await
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
154
|
+
const uploadResult = await uploadBuild(
|
|
155
|
+
apiClient,
|
|
156
|
+
{
|
|
157
|
+
project: argv.project,
|
|
158
|
+
version: argv.version,
|
|
159
|
+
},
|
|
160
|
+
{ zipPath: outPath, coverageReport }
|
|
161
|
+
);
|
|
147
162
|
logger.success('โ
Archive uploaded.');
|
|
148
163
|
logger.debug(`Upload result: ${JSON.stringify(uploadResult)}`);
|
|
149
164
|
|
|
165
|
+
await postPRComment(buildDeployResult(argv, coverageSummary), coverageSummary);
|
|
166
|
+
|
|
150
167
|
logger.success('\n๐ Deployment successful! ๐');
|
|
151
168
|
}
|
|
152
169
|
|
|
@@ -159,10 +176,26 @@ async function runDeployment(argv) {
|
|
|
159
176
|
}
|
|
160
177
|
}
|
|
161
178
|
|
|
162
|
-
function handleError(error, argv) {
|
|
179
|
+
async function handleError(error, argv) {
|
|
163
180
|
const logger = createLogger(argv || {});
|
|
164
181
|
logger.error(`\nโ Error: ${error.message}`);
|
|
165
182
|
|
|
183
|
+
// Capture error in Sentry with additional context
|
|
184
|
+
Sentry.withScope((scope) => {
|
|
185
|
+
if (argv) {
|
|
186
|
+
scope.setTags({
|
|
187
|
+
project: argv.project,
|
|
188
|
+
version: argv.version,
|
|
189
|
+
command: argv._ ? argv._[0] : 'unknown',
|
|
190
|
+
});
|
|
191
|
+
scope.setExtra('argv', argv);
|
|
192
|
+
}
|
|
193
|
+
Sentry.captureException(error);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Ensure the event is sent before the process exits
|
|
197
|
+
await Sentry.close(2000);
|
|
198
|
+
|
|
166
199
|
if (error instanceof ApiError) {
|
|
167
200
|
if (error.statusCode === 401) {
|
|
168
201
|
logger.error('Suggestion: Check that your API key is correct and has not expired.');
|
|
@@ -179,6 +212,13 @@ function handleError(error, argv) {
|
|
|
179
212
|
}
|
|
180
213
|
|
|
181
214
|
async function main() {
|
|
215
|
+
// Initialize Sentry
|
|
216
|
+
Sentry.init({
|
|
217
|
+
dsn: "https://c66ce229a1db2289f145eebd02436d9c@o4507889391828992.ingest.us.sentry.io/4510699330732032", // Fallback to hardcoded DSN for user reporting
|
|
218
|
+
tracesSampleRate: 1.0,
|
|
219
|
+
environment: process.env.NODE_ENV || 'production',
|
|
220
|
+
});
|
|
221
|
+
|
|
182
222
|
let config;
|
|
183
223
|
try {
|
|
184
224
|
const args = await yargs(hideBin(process.argv))
|
|
@@ -201,10 +241,35 @@ async function main() {
|
|
|
201
241
|
type: 'string',
|
|
202
242
|
})
|
|
203
243
|
.option('deploy-version', {
|
|
204
|
-
alias: 'v',
|
|
244
|
+
alias: ['v', 'version'],
|
|
205
245
|
describe: 'Version identifier for the deployment',
|
|
206
246
|
type: 'string',
|
|
207
247
|
})
|
|
248
|
+
// Coverage options (enabled by default)
|
|
249
|
+
.option('coverage', {
|
|
250
|
+
describe: 'Run coverage analysis and upload report',
|
|
251
|
+
type: 'boolean',
|
|
252
|
+
default: true,
|
|
253
|
+
})
|
|
254
|
+
.option('coverage-report', {
|
|
255
|
+
describe: 'Path to coverage report JSON file (skip analysis and upload this report)',
|
|
256
|
+
type: 'string',
|
|
257
|
+
})
|
|
258
|
+
.option('coverage-fail-on-threshold', {
|
|
259
|
+
describe: 'Fail if coverage thresholds are not met',
|
|
260
|
+
type: 'boolean',
|
|
261
|
+
default: false,
|
|
262
|
+
})
|
|
263
|
+
.option('coverage-base', {
|
|
264
|
+
describe: 'Base branch for new code analysis',
|
|
265
|
+
type: 'string',
|
|
266
|
+
default: 'main',
|
|
267
|
+
})
|
|
268
|
+
.option('coverage-execute', {
|
|
269
|
+
describe: 'Execute stories during coverage analysis',
|
|
270
|
+
type: 'boolean',
|
|
271
|
+
default: false,
|
|
272
|
+
})
|
|
208
273
|
.option('with-analysis', {
|
|
209
274
|
describe: 'Include Storybook analysis (screenshots, metadata)',
|
|
210
275
|
type: 'boolean',
|
|
@@ -337,6 +402,9 @@ async function main() {
|
|
|
337
402
|
|
|
338
403
|
await runInit(initConfig);
|
|
339
404
|
})
|
|
405
|
+
.command('debug-sentry', 'Test Sentry integration by throwing an error', () => {}, () => {
|
|
406
|
+
throw new Error('Sentry debug error from scry-node CLI');
|
|
407
|
+
})
|
|
340
408
|
.env('STORYBOOK_DEPLOYER')
|
|
341
409
|
.help()
|
|
342
410
|
.alias('help', 'h')
|
|
@@ -344,8 +412,87 @@ async function main() {
|
|
|
344
412
|
.parse();
|
|
345
413
|
|
|
346
414
|
} catch (error) {
|
|
347
|
-
handleError(error, config);
|
|
415
|
+
await handleError(error, config);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Resolve coverage settings into a report and a summary.
|
|
421
|
+
*
|
|
422
|
+
* @param {any} argv
|
|
423
|
+
* @param {{info:Function,debug:Function,success:Function,error:Function}} logger
|
|
424
|
+
*/
|
|
425
|
+
async function resolveCoverage(argv, logger) {
|
|
426
|
+
const enabled = argv.coverage !== false;
|
|
427
|
+
if (!enabled) {
|
|
428
|
+
logger.info('Coverage: disabled (--no-coverage)');
|
|
429
|
+
return { coverageReport: null, coverageSummary: null };
|
|
348
430
|
}
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
let report = null;
|
|
434
|
+
|
|
435
|
+
if (argv.coverageReport) {
|
|
436
|
+
logger.info(`Coverage: using existing report at ${argv.coverageReport}`);
|
|
437
|
+
report = loadCoverageReport(argv.coverageReport);
|
|
438
|
+
} else {
|
|
439
|
+
report = await runCoverageAnalysis({
|
|
440
|
+
storybookDir: argv.dir,
|
|
441
|
+
baseBranch: argv.coverageBase || 'main',
|
|
442
|
+
failOnThreshold: Boolean(argv.coverageFailOnThreshold),
|
|
443
|
+
execute: Boolean(argv.coverageExecute),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const summary = extractCoverageSummary(report);
|
|
448
|
+
if (summary) {
|
|
449
|
+
logger.success('โ
Coverage report ready');
|
|
450
|
+
logger.debug(`Coverage summary: ${JSON.stringify(summary.summary)}`);
|
|
451
|
+
} else {
|
|
452
|
+
logger.info('Coverage: no report generated (tool failed or report shape unexpected)');
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return { coverageReport: report, coverageSummary: summary };
|
|
456
|
+
} catch (err) {
|
|
457
|
+
logger.error(`Coverage: failed (${err.message})`);
|
|
458
|
+
throw err;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Construct public URLs for view and coverage assets.
|
|
464
|
+
*
|
|
465
|
+
* @param {any} argv
|
|
466
|
+
* @param {any|null} coverageSummary
|
|
467
|
+
*/
|
|
468
|
+
function buildDeployResult(argv, coverageSummary) {
|
|
469
|
+
const project = argv.project || 'main';
|
|
470
|
+
const version = argv.version || 'latest';
|
|
471
|
+
const viewBaseUrl = process.env.SCRY_VIEW_URL || 'https://view.scrymore.com';
|
|
472
|
+
|
|
473
|
+
const viewUrl = `${viewBaseUrl.replace(/\/$/, '')}/${project}/${version}/`;
|
|
474
|
+
|
|
475
|
+
const coverageUrl = coverageSummary
|
|
476
|
+
? `${viewBaseUrl.replace(/\/$/, '')}/${project}/${version}/coverage-report.json`
|
|
477
|
+
: null;
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
project,
|
|
481
|
+
version,
|
|
482
|
+
viewUrl,
|
|
483
|
+
coverageUrl,
|
|
484
|
+
coveragePageUrl: coverageUrl,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (require.main === module) {
|
|
489
|
+
main();
|
|
349
490
|
}
|
|
350
491
|
|
|
351
|
-
|
|
492
|
+
module.exports = {
|
|
493
|
+
main,
|
|
494
|
+
runDeployment,
|
|
495
|
+
runAnalysis,
|
|
496
|
+
resolveCoverage,
|
|
497
|
+
buildDeployResult,
|
|
498
|
+
};
|
package/lib/apiClient.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const axios = require('axios');
|
|
2
2
|
const fs = require('fs');
|
|
3
|
-
const { ApiError
|
|
3
|
+
const { ApiError } = require('./errors.js');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Creates a pre-configured axios instance for making API calls.
|
|
@@ -30,101 +30,176 @@ function getApiClient(apiUrl, apiKey) {
|
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Request a presigned URL from the backend.
|
|
35
|
+
*
|
|
36
|
+
* @param {axios.AxiosInstance} apiClient
|
|
37
|
+
* @param {{project: string, version: string}} target
|
|
38
|
+
* @param {{fileName: string, contentType: string}} file
|
|
39
|
+
* @returns {Promise<string>} presigned URL
|
|
40
|
+
*/
|
|
41
|
+
async function requestPresignedUrl(apiClient, target, file) {
|
|
42
|
+
const projectName = target.project || 'main';
|
|
43
|
+
const versionName = target.version || 'latest';
|
|
44
|
+
|
|
45
|
+
const presignedResponse = await apiClient.post(
|
|
46
|
+
`/presigned-url/${projectName}/${versionName}/${file.fileName}`,
|
|
47
|
+
{ contentType: file.contentType },
|
|
48
|
+
{
|
|
49
|
+
headers: {
|
|
50
|
+
'Content-Type': 'application/json',
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const presignedUrl = presignedResponse.data?.url;
|
|
56
|
+
if (!presignedUrl || typeof presignedUrl !== 'string' || presignedUrl.trim() === '') {
|
|
57
|
+
throw new ApiError(
|
|
58
|
+
`Failed to get valid presigned URL from server response. Received: ${JSON.stringify(presignedResponse.data)}`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Validate URL format
|
|
63
|
+
try {
|
|
64
|
+
new URL(presignedUrl);
|
|
65
|
+
} catch (urlError) {
|
|
66
|
+
throw new ApiError(`Received invalid URL format from server: "${presignedUrl}". URL validation error: ${urlError.message}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return presignedUrl;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Upload a buffer to a presigned URL.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} presignedUrl
|
|
76
|
+
* @param {Buffer} buffer
|
|
77
|
+
* @param {string} contentType
|
|
78
|
+
* @returns {Promise<{status:number}>}
|
|
79
|
+
*/
|
|
80
|
+
async function putToPresignedUrl(presignedUrl, buffer, contentType) {
|
|
81
|
+
const uploadResponse = await axios.put(presignedUrl, buffer, {
|
|
82
|
+
headers: {
|
|
83
|
+
'Content-Type': contentType,
|
|
84
|
+
},
|
|
85
|
+
maxContentLength: Infinity,
|
|
86
|
+
maxBodyLength: Infinity,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return { status: uploadResponse.status };
|
|
90
|
+
}
|
|
91
|
+
|
|
33
92
|
/**
|
|
34
93
|
* Uploads a file using a presigned URL workflow.
|
|
94
|
+
*
|
|
35
95
|
* @param {axios.AxiosInstance} apiClient The configured axios instance.
|
|
36
96
|
* @param {object} payload The metadata for the deployment.
|
|
37
97
|
* @param {string} payload.project The project name/identifier.
|
|
38
98
|
* @param {string} payload.version The version identifier.
|
|
39
99
|
* @param {string} filePath The local path to the file to upload.
|
|
100
|
+
* @param {{fileName?: string, contentType?: string}} [file] Optional overrides
|
|
40
101
|
* @returns {Promise<object>} A promise that resolves to the upload result.
|
|
41
102
|
*/
|
|
42
|
-
async function uploadFileDirectly(apiClient, { project, version }, filePath) {
|
|
103
|
+
async function uploadFileDirectly(apiClient, { project, version }, filePath, file = {}) {
|
|
43
104
|
// This is a mock check to allow testing of a 500 server error.
|
|
44
105
|
if (project === 'fail-me-500') {
|
|
45
106
|
throw new ApiError('The deployment service encountered an internal error.', 500);
|
|
46
107
|
}
|
|
47
108
|
|
|
48
|
-
// Default to 'main' and 'latest' if not provided
|
|
49
|
-
const projectName = project || 'main';
|
|
50
|
-
const versionName = version || 'latest';
|
|
51
|
-
|
|
52
109
|
const fileBuffer = fs.readFileSync(filePath);
|
|
53
|
-
const fileName = 'storybook.zip';
|
|
110
|
+
const fileName = file.fileName || 'storybook.zip';
|
|
111
|
+
const contentType = file.contentType || 'application/zip';
|
|
54
112
|
|
|
55
113
|
try {
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// The server expects contentType in the JSON body, NOT as a header
|
|
62
|
-
// This contentType must match the Content-Type header used during the actual upload
|
|
63
|
-
const presignedResponse = await apiClient.post(
|
|
64
|
-
`/presigned-url/${projectName}/${versionName}/${fileName}`,
|
|
65
|
-
{ contentType: 'application/zip' }, // Send contentType in the body
|
|
66
|
-
{
|
|
67
|
-
headers: {
|
|
68
|
-
'Content-Type': 'application/json', // Request body is JSON
|
|
69
|
-
},
|
|
70
|
-
}
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
const presignedUrlDuration = Date.now() - presignedUrlStartTime;
|
|
74
|
-
console.log(`[TIMING] Presigned URL request: ${presignedUrlDuration}ms`);
|
|
75
|
-
console.log(`[DEBUG] Presigned URL response status: ${presignedResponse.status}`);
|
|
76
|
-
console.log(`[DEBUG] Presigned URL response data: ${JSON.stringify(presignedResponse.data)}`);
|
|
77
|
-
|
|
78
|
-
const presignedUrl = presignedResponse.data.url;
|
|
79
|
-
if (!presignedUrl || typeof presignedUrl !== 'string' || presignedUrl.trim() === '') {
|
|
114
|
+
const presignedUrl = await requestPresignedUrl(apiClient, { project, version }, { fileName, contentType });
|
|
115
|
+
const upload = await putToPresignedUrl(presignedUrl, fileBuffer, contentType);
|
|
116
|
+
return { success: true, url: presignedUrl, status: upload.status };
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error.response) {
|
|
80
119
|
throw new ApiError(
|
|
81
|
-
`Failed to
|
|
82
|
-
|
|
120
|
+
`Failed to upload file: ${error.response.status} ${error.response.statusText}${error.response.data ? ` - ${JSON.stringify(error.response.data)}` : ''}`,
|
|
121
|
+
error.response.status
|
|
83
122
|
);
|
|
123
|
+
} else if (error.request) {
|
|
124
|
+
throw new ApiError(`Failed to upload file: No response from server at ${apiClient.defaults.baseURL}`);
|
|
125
|
+
} else {
|
|
126
|
+
throw new ApiError(`Failed to upload file: ${error.message}`);
|
|
84
127
|
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
85
130
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Upload coverage report via the coverage attach endpoint.
|
|
133
|
+
* This uploads the JSON to R2 and attaches normalized coverage to the build.
|
|
134
|
+
*
|
|
135
|
+
* @param {axios.AxiosInstance} apiClient
|
|
136
|
+
* @param {{project: string, version: string}} target
|
|
137
|
+
* @param {any} coverageReport
|
|
138
|
+
* @returns {Promise<{success: boolean, buildId?: string, coverageUrl?: string}>}
|
|
139
|
+
*/
|
|
140
|
+
async function uploadCoverageReportDirectly(apiClient, target, coverageReport) {
|
|
141
|
+
const projectName = target.project || 'main';
|
|
142
|
+
const versionName = target.version || 'latest';
|
|
95
143
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
144
|
+
try {
|
|
145
|
+
const response = await apiClient.post(
|
|
146
|
+
`/upload/${projectName}/${versionName}/coverage`,
|
|
147
|
+
coverageReport,
|
|
148
|
+
{
|
|
149
|
+
headers: {
|
|
150
|
+
'Content-Type': 'application/json',
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
);
|
|
99
154
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
},
|
|
106
|
-
maxContentLength: Infinity,
|
|
107
|
-
maxBodyLength: Infinity,
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
const uploadDuration = Date.now() - uploadStartTime;
|
|
111
|
-
const uploadSpeedMbps = ((fileBuffer.length / 1024 / 1024) / (uploadDuration / 1000)).toFixed(2);
|
|
112
|
-
console.log(`[TIMING] File upload: ${uploadDuration}ms (${uploadSpeedMbps} MB/s)`);
|
|
113
|
-
console.log(`[DEBUG] File uploaded successfully`);
|
|
114
|
-
|
|
115
|
-
return { success: true, url: presignedUrl, status: uploadResponse.status };
|
|
155
|
+
return {
|
|
156
|
+
success: response.data?.success ?? true,
|
|
157
|
+
buildId: response.data?.buildId,
|
|
158
|
+
coverageUrl: response.data?.coverageUrl,
|
|
159
|
+
};
|
|
116
160
|
} catch (error) {
|
|
117
161
|
if (error.response) {
|
|
118
|
-
throw new ApiError(
|
|
162
|
+
throw new ApiError(
|
|
163
|
+
`Failed to upload coverage: ${error.response.status} ${error.response.statusText}${error.response.data ? ` - ${JSON.stringify(error.response.data)}` : ''}`,
|
|
164
|
+
error.response.status
|
|
165
|
+
);
|
|
119
166
|
} else if (error.request) {
|
|
120
|
-
throw new ApiError(`Failed to upload
|
|
167
|
+
throw new ApiError(`Failed to upload coverage: No response from server at ${apiClient.defaults.baseURL}`);
|
|
121
168
|
} else {
|
|
122
|
-
throw new ApiError(`Failed to upload
|
|
169
|
+
throw new ApiError(`Failed to upload coverage: ${error.message}`);
|
|
123
170
|
}
|
|
124
171
|
}
|
|
125
172
|
}
|
|
126
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Upload storybook zip plus optional coverage report.
|
|
176
|
+
*
|
|
177
|
+
* NOTE: The backend currently supports uploads via presigned URLs only.
|
|
178
|
+
* This helper keeps the orchestration in one place.
|
|
179
|
+
*
|
|
180
|
+
* @param {axios.AxiosInstance} apiClient
|
|
181
|
+
* @param {{project: string, version: string}} target
|
|
182
|
+
* @param {{zipPath: string, coverageReport?: any|null}} options
|
|
183
|
+
*/
|
|
184
|
+
async function uploadBuild(apiClient, target, options) {
|
|
185
|
+
const zipUpload = await uploadFileDirectly(apiClient, target, options.zipPath, {
|
|
186
|
+
fileName: 'storybook.zip',
|
|
187
|
+
contentType: 'application/zip',
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
let coverageUpload = null;
|
|
191
|
+
if (options.coverageReport) {
|
|
192
|
+
coverageUpload = await uploadCoverageReportDirectly(apiClient, target, options.coverageReport);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { zipUpload, coverageUpload };
|
|
196
|
+
}
|
|
197
|
+
|
|
127
198
|
module.exports = {
|
|
128
199
|
getApiClient,
|
|
129
200
|
uploadFileDirectly,
|
|
201
|
+
uploadCoverageReportDirectly,
|
|
202
|
+
uploadBuild,
|
|
203
|
+
requestPresignedUrl,
|
|
204
|
+
putToPresignedUrl,
|
|
130
205
|
};
|
package/lib/config.js
CHANGED
|
@@ -27,7 +27,12 @@ const DEFAULT_CONFIG = {
|
|
|
27
27
|
include: undefined,
|
|
28
28
|
exclude: undefined,
|
|
29
29
|
omitBackground: true
|
|
30
|
-
}
|
|
30
|
+
},
|
|
31
|
+
// Coverage options (enabled by default)
|
|
32
|
+
coverage: true,
|
|
33
|
+
coverageReport: null,
|
|
34
|
+
coverageFailOnThreshold: false,
|
|
35
|
+
coverageBase: 'main'
|
|
31
36
|
};
|
|
32
37
|
|
|
33
38
|
/**
|
|
@@ -102,7 +107,7 @@ function detectGitHubActionsContext() {
|
|
|
102
107
|
*/
|
|
103
108
|
function loadEnvConfig() {
|
|
104
109
|
const envConfig = {};
|
|
105
|
-
|
|
110
|
+
|
|
106
111
|
// Map environment variable names to config keys
|
|
107
112
|
const envMapping = {
|
|
108
113
|
'API_URL': 'apiUrl',
|
|
@@ -116,14 +121,14 @@ function loadEnvConfig() {
|
|
|
116
121
|
'SCREENSHOTS_DIR': 'screenshotsDir',
|
|
117
122
|
'STORYBOOK_URL': 'storybookUrl'
|
|
118
123
|
};
|
|
119
|
-
|
|
124
|
+
|
|
120
125
|
Object.keys(envMapping).forEach(envKey => {
|
|
121
126
|
const configKey = envMapping[envKey];
|
|
122
|
-
|
|
127
|
+
|
|
123
128
|
// Check SCRY_ prefix first (new standard), then fall back to STORYBOOK_DEPLOYER_ for backward compatibility
|
|
124
129
|
const scryEnvKey = 'SCRY_' + envKey;
|
|
125
130
|
const legacyEnvKey = 'STORYBOOK_DEPLOYER_' + envKey;
|
|
126
|
-
|
|
131
|
+
|
|
127
132
|
// Handle special case for PROJECT_ID vs PROJECT
|
|
128
133
|
let envValue = process.env[scryEnvKey];
|
|
129
134
|
if (!envValue && envKey === 'PROJECT') {
|
|
@@ -132,7 +137,7 @@ function loadEnvConfig() {
|
|
|
132
137
|
if (!envValue) {
|
|
133
138
|
envValue = process.env[legacyEnvKey];
|
|
134
139
|
}
|
|
135
|
-
|
|
140
|
+
|
|
136
141
|
// Filter out undefined and empty string values to prevent overriding CLI args
|
|
137
142
|
if (envValue !== undefined && envValue !== '') {
|
|
138
143
|
// Convert string values to appropriate types
|
|
@@ -143,7 +148,27 @@ function loadEnvConfig() {
|
|
|
143
148
|
}
|
|
144
149
|
}
|
|
145
150
|
});
|
|
146
|
-
|
|
151
|
+
|
|
152
|
+
// Coverage env vars (spec names)
|
|
153
|
+
if (process.env.SCRY_COVERAGE_ENABLED !== undefined && process.env.SCRY_COVERAGE_ENABLED !== '') {
|
|
154
|
+
envConfig.coverage = process.env.SCRY_COVERAGE_ENABLED.toLowerCase() !== 'false';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (process.env.SCRY_COVERAGE_REPORT !== undefined && process.env.SCRY_COVERAGE_REPORT !== '') {
|
|
158
|
+
envConfig.coverageReport = process.env.SCRY_COVERAGE_REPORT;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
process.env.SCRY_COVERAGE_FAIL_ON_THRESHOLD !== undefined &&
|
|
163
|
+
process.env.SCRY_COVERAGE_FAIL_ON_THRESHOLD !== ''
|
|
164
|
+
) {
|
|
165
|
+
envConfig.coverageFailOnThreshold = process.env.SCRY_COVERAGE_FAIL_ON_THRESHOLD.toLowerCase() === 'true';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (process.env.SCRY_COVERAGE_BASE !== undefined && process.env.SCRY_COVERAGE_BASE !== '') {
|
|
169
|
+
envConfig.coverageBase = process.env.SCRY_COVERAGE_BASE;
|
|
170
|
+
}
|
|
171
|
+
|
|
147
172
|
return envConfig;
|
|
148
173
|
}
|
|
149
174
|
|
|
@@ -153,7 +178,7 @@ function loadEnvConfig() {
|
|
|
153
178
|
function loadConfig(cliArgs = {}) {
|
|
154
179
|
const configPath = getConfigPath();
|
|
155
180
|
let fileConfig = {};
|
|
156
|
-
|
|
181
|
+
|
|
157
182
|
if (fs.existsSync(configPath)) {
|
|
158
183
|
try {
|
|
159
184
|
const configContent = fs.readFileSync(configPath, 'utf8');
|
|
@@ -162,31 +187,23 @@ function loadConfig(cliArgs = {}) {
|
|
|
162
187
|
console.warn(`โ ๏ธ Could not read config file at ${configPath}: ${error.message}`);
|
|
163
188
|
}
|
|
164
189
|
}
|
|
165
|
-
|
|
190
|
+
|
|
166
191
|
// Load environment variables
|
|
167
192
|
const envConfig = loadEnvConfig();
|
|
168
|
-
|
|
193
|
+
|
|
169
194
|
// Detect GitHub Actions context
|
|
170
195
|
const githubContext = detectGitHubActionsContext();
|
|
171
|
-
|
|
172
|
-
// Log configuration sources for debugging
|
|
173
|
-
console.log('[CONFIG] Configuration sources:');
|
|
174
|
-
console.log(`[CONFIG] - Default apiUrl: ${DEFAULT_CONFIG.apiUrl}`);
|
|
175
|
-
console.log(`[CONFIG] - File config apiUrl: ${fileConfig.apiUrl || 'not set'}`);
|
|
176
|
-
console.log(`[CONFIG] - Env SCRY_API_URL: ${process.env.SCRY_API_URL || 'not set'}`);
|
|
177
|
-
console.log(`[CONFIG] - Env STORYBOOK_DEPLOYER_API_URL: ${process.env.STORYBOOK_DEPLOYER_API_URL || 'not set'}`);
|
|
178
|
-
console.log(`[CONFIG] - CLI args apiUrl: ${cliArgs.apiUrl || 'not set'}`);
|
|
179
|
-
|
|
196
|
+
|
|
180
197
|
// Precedence: CLI arguments > GitHub Actions context > environment variables > file config > defaults
|
|
181
198
|
// This ensures GitHub Actions auto-detection works even if env vars are set to empty strings
|
|
182
|
-
|
|
199
|
+
|
|
183
200
|
// Map 'deployVersion' (from yargs --deploy-version) to 'version' for internal use
|
|
184
201
|
const normalizedCliArgs = { ...cliArgs };
|
|
185
202
|
if (normalizedCliArgs.deployVersion) {
|
|
186
203
|
normalizedCliArgs.version = normalizedCliArgs.deployVersion;
|
|
187
204
|
delete normalizedCliArgs.deployVersion;
|
|
188
205
|
}
|
|
189
|
-
|
|
206
|
+
|
|
190
207
|
const finalConfig = {
|
|
191
208
|
...DEFAULT_CONFIG,
|
|
192
209
|
...fileConfig,
|
|
@@ -194,11 +211,22 @@ function loadConfig(cliArgs = {}) {
|
|
|
194
211
|
...githubContext,
|
|
195
212
|
...normalizedCliArgs
|
|
196
213
|
};
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
214
|
+
|
|
215
|
+
const debugConfig = Boolean(finalConfig.verbose);
|
|
216
|
+
if (debugConfig) {
|
|
217
|
+
// Log configuration sources for debugging
|
|
218
|
+
console.log('[CONFIG] Configuration sources:');
|
|
219
|
+
console.log(`[CONFIG] - Default apiUrl: ${DEFAULT_CONFIG.apiUrl}`);
|
|
220
|
+
console.log(`[CONFIG] - File config apiUrl: ${fileConfig.apiUrl || 'not set'}`);
|
|
221
|
+
console.log(`[CONFIG] - Env SCRY_API_URL: ${process.env.SCRY_API_URL || 'not set'}`);
|
|
222
|
+
console.log(`[CONFIG] - Env STORYBOOK_DEPLOYER_API_URL: ${process.env.STORYBOOK_DEPLOYER_API_URL || 'not set'}`);
|
|
223
|
+
console.log(`[CONFIG] - CLI args apiUrl: ${cliArgs.apiUrl || 'not set'}`);
|
|
224
|
+
console.log(`[CONFIG] Final apiUrl: ${finalConfig.apiUrl}`);
|
|
225
|
+
console.log(`[CONFIG] Final project: ${finalConfig.project}`);
|
|
226
|
+
console.log(`[CONFIG] Final version: ${finalConfig.version}`);
|
|
227
|
+
console.log(`[CONFIG] Final coverage: ${finalConfig.coverage}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
202
230
|
return finalConfig;
|
|
203
231
|
}
|
|
204
232
|
|
package/lib/coverage.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} RunCoverageOptions
|
|
8
|
+
* @property {string} storybookDir Path to a built Storybook static directory (e.g. ./storybook-static)
|
|
9
|
+
* @property {string} [baseBranch='main'] Base branch name to compare for "new code" analysis
|
|
10
|
+
* @property {boolean} [failOnThreshold=false] If true, pass "--ci" to the coverage tool and rethrow errors
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run Storybook coverage analysis via `@scrymore/scry-sbcov`.
|
|
15
|
+
*
|
|
16
|
+
* Behavior:
|
|
17
|
+
* - Writes a temporary report file in the current working directory
|
|
18
|
+
* - Executes the `@scrymore/scry-sbcov` CLI
|
|
19
|
+
* - Reads and returns the parsed JSON report
|
|
20
|
+
* - Deletes the temporary report file
|
|
21
|
+
*
|
|
22
|
+
* If the underlying tool fails and `failOnThreshold` is false, returns `null`.
|
|
23
|
+
*
|
|
24
|
+
* @param {RunCoverageOptions} options
|
|
25
|
+
* @returns {Promise<any|null>} The full coverage report JSON, or null if skipped/failed (non-fatal)
|
|
26
|
+
*/
|
|
27
|
+
async function runCoverageAnalysis(options) {
|
|
28
|
+
const { storybookDir, baseBranch = 'main', failOnThreshold = false, execute = false } = options || {};
|
|
29
|
+
|
|
30
|
+
if (!storybookDir || typeof storybookDir !== 'string') {
|
|
31
|
+
throw new Error('runCoverageAnalysis: options.storybookDir is required');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log(chalk.blue('Running Storybook coverage analysis...'));
|
|
35
|
+
|
|
36
|
+
const outputPath = path.join(process.cwd(), `.scry-coverage-report-${Date.now()}.json`);
|
|
37
|
+
|
|
38
|
+
/** @type {string[]} */
|
|
39
|
+
const cliArgs = [
|
|
40
|
+
'--storybook-static',
|
|
41
|
+
storybookDir,
|
|
42
|
+
'--output',
|
|
43
|
+
outputPath,
|
|
44
|
+
'--base',
|
|
45
|
+
`origin/${baseBranch}`,
|
|
46
|
+
'--verbose', // Enable verbose logging to debug component detection
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
if (failOnThreshold) {
|
|
50
|
+
cliArgs.push('--ci');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (execute) {
|
|
54
|
+
cliArgs.push('--execute');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Use npx with -p flag to ensure package is installed, then run the binary
|
|
58
|
+
// This is more reliable than `npx @scrymore/scry-sbcov` which can fail to find the binary
|
|
59
|
+
const npxCommand = `npx -y -p @scrymore/scry-sbcov scry-sbcov ${cliArgs.map(shellEscape).join(' ')}`;
|
|
60
|
+
|
|
61
|
+
// Debug logging to show the exact command being executed
|
|
62
|
+
console.log(chalk.yellow('\nโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ'));
|
|
63
|
+
console.log(chalk.yellow('DEBUG: Executing coverage command:'));
|
|
64
|
+
console.log(chalk.gray(npxCommand));
|
|
65
|
+
console.log(chalk.yellow('Working directory: ' + process.cwd()));
|
|
66
|
+
console.log(chalk.yellow('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n'));
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
// Determine the correct working directory
|
|
70
|
+
// If storybookDir is relative, resolve it from cwd
|
|
71
|
+
// Then use its parent directory as the project root
|
|
72
|
+
const absoluteStorybookDir = path.isAbsolute(storybookDir)
|
|
73
|
+
? storybookDir
|
|
74
|
+
: path.resolve(process.cwd(), storybookDir);
|
|
75
|
+
|
|
76
|
+
const projectRoot = path.dirname(absoluteStorybookDir);
|
|
77
|
+
|
|
78
|
+
console.log(chalk.yellow('Project root: ' + projectRoot));
|
|
79
|
+
console.log(chalk.yellow('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n'));
|
|
80
|
+
|
|
81
|
+
execSync(npxCommand, {
|
|
82
|
+
stdio: 'inherit',
|
|
83
|
+
cwd: projectRoot // Run from the project root, not scry-node directory
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const raw = fs.readFileSync(outputPath, 'utf-8');
|
|
87
|
+
const report = JSON.parse(raw);
|
|
88
|
+
|
|
89
|
+
safeUnlink(outputPath);
|
|
90
|
+
return report;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
safeUnlink(outputPath);
|
|
93
|
+
if (failOnThreshold) throw error;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Load a coverage report from disk.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} reportPath
|
|
102
|
+
* @returns {any}
|
|
103
|
+
*/
|
|
104
|
+
function loadCoverageReport(reportPath) {
|
|
105
|
+
if (!reportPath || typeof reportPath !== 'string') {
|
|
106
|
+
throw new Error('loadCoverageReport: reportPath is required');
|
|
107
|
+
}
|
|
108
|
+
const raw = fs.readFileSync(reportPath, 'utf-8');
|
|
109
|
+
return JSON.parse(raw);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Extracts a stable, API-friendly subset of the full report.
|
|
114
|
+
*
|
|
115
|
+
* NOTE: This function is intentionally defensive: if the report shape changes,
|
|
116
|
+
* we return `null` instead of throwing to avoid breaking deployments.
|
|
117
|
+
*
|
|
118
|
+
* @param {any|null} report
|
|
119
|
+
* @returns {null|{
|
|
120
|
+
* reportUrl: string|null,
|
|
121
|
+
* summary: {
|
|
122
|
+
* componentCoverage: number,
|
|
123
|
+
* propCoverage: number,
|
|
124
|
+
* variantCoverage: number,
|
|
125
|
+
* passRate: number,
|
|
126
|
+
* totalComponents: number,
|
|
127
|
+
* componentsWithStories: number,
|
|
128
|
+
* failingStories: number
|
|
129
|
+
* },
|
|
130
|
+
* qualityGate: any,
|
|
131
|
+
* generatedAt: string
|
|
132
|
+
* }}
|
|
133
|
+
*/
|
|
134
|
+
function extractCoverageSummary(report) {
|
|
135
|
+
if (!report) return null;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
return {
|
|
139
|
+
reportUrl: null,
|
|
140
|
+
summary: {
|
|
141
|
+
componentCoverage: report.summary.metrics.componentCoverage,
|
|
142
|
+
propCoverage: report.summary.metrics.propCoverage,
|
|
143
|
+
variantCoverage: report.summary.metrics.variantCoverage,
|
|
144
|
+
passRate: report.summary.health.passRate,
|
|
145
|
+
totalComponents: report.summary.totalComponents,
|
|
146
|
+
componentsWithStories: report.summary.componentsWithStories,
|
|
147
|
+
failingStories: report.summary.health.failingStories,
|
|
148
|
+
},
|
|
149
|
+
qualityGate: report.qualityGate,
|
|
150
|
+
generatedAt: report.generatedAt,
|
|
151
|
+
};
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Best-effort deletion: ignore ENOENT and other fs errors.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} filePath
|
|
161
|
+
*/
|
|
162
|
+
function safeUnlink(filePath) {
|
|
163
|
+
try {
|
|
164
|
+
if (fs.existsSync(filePath)) {
|
|
165
|
+
fs.unlinkSync(filePath);
|
|
166
|
+
}
|
|
167
|
+
} catch (_) {
|
|
168
|
+
// ignore
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Minimal shell escaping for arguments.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} value
|
|
176
|
+
* @returns {string}
|
|
177
|
+
*/
|
|
178
|
+
function shellEscape(value) {
|
|
179
|
+
if (typeof value !== 'string') return '';
|
|
180
|
+
if (/^[a-zA-Z0-9_\-./:@]+$/.test(value)) return value;
|
|
181
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
runCoverageAnalysis,
|
|
186
|
+
loadCoverageReport,
|
|
187
|
+
extractCoverageSummary,
|
|
188
|
+
};
|
package/lib/logger.js
CHANGED
|
@@ -8,6 +8,7 @@ const chalk = require('chalk');
|
|
|
8
8
|
* @returns {{info: Function, error: Function, debug: Function, success: Function}}
|
|
9
9
|
*/
|
|
10
10
|
function createLogger({ verbose = false }) {
|
|
11
|
+
const Sentry = require('@sentry/node');
|
|
11
12
|
return {
|
|
12
13
|
/**
|
|
13
14
|
* Logs an informational message.
|
|
@@ -15,6 +16,11 @@ function createLogger({ verbose = false }) {
|
|
|
15
16
|
*/
|
|
16
17
|
info: (message) => {
|
|
17
18
|
console.log(message);
|
|
19
|
+
Sentry.addBreadcrumb({
|
|
20
|
+
category: 'log',
|
|
21
|
+
message: message,
|
|
22
|
+
level: 'info',
|
|
23
|
+
});
|
|
18
24
|
},
|
|
19
25
|
|
|
20
26
|
/**
|
|
@@ -31,6 +37,11 @@ function createLogger({ verbose = false }) {
|
|
|
31
37
|
*/
|
|
32
38
|
error: (message) => {
|
|
33
39
|
console.error(chalk.red(message));
|
|
40
|
+
Sentry.addBreadcrumb({
|
|
41
|
+
category: 'log',
|
|
42
|
+
message: message,
|
|
43
|
+
level: 'error',
|
|
44
|
+
});
|
|
34
45
|
},
|
|
35
46
|
|
|
36
47
|
/**
|
|
@@ -41,6 +52,11 @@ function createLogger({ verbose = false }) {
|
|
|
41
52
|
if (verbose) {
|
|
42
53
|
console.log(chalk.dim(`[debug] ${message}`));
|
|
43
54
|
}
|
|
55
|
+
Sentry.addBreadcrumb({
|
|
56
|
+
category: 'log',
|
|
57
|
+
message: message,
|
|
58
|
+
level: 'debug',
|
|
59
|
+
});
|
|
44
60
|
},
|
|
45
61
|
};
|
|
46
62
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const { Octokit } = require('@octokit/rest');
|
|
2
|
+
|
|
3
|
+
const COMMENT_MARKER = '<!-- scry-deployer -->';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Post (create or update) a PR comment with deployment and optional coverage info.
|
|
7
|
+
*
|
|
8
|
+
* This function is no-op unless:
|
|
9
|
+
* - `GITHUB_TOKEN` is set
|
|
10
|
+
* - The workflow context includes a pull request number
|
|
11
|
+
*
|
|
12
|
+
* @param {object} deployResult
|
|
13
|
+
* @param {any|null} coverageSummary Coverage summary (typically from extractCoverageSummary())
|
|
14
|
+
* @returns {Promise<void>}
|
|
15
|
+
*/
|
|
16
|
+
async function postPRComment(deployResult, coverageSummary) {
|
|
17
|
+
const token = process.env.GITHUB_TOKEN;
|
|
18
|
+
if (!token) return;
|
|
19
|
+
|
|
20
|
+
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
21
|
+
if (!eventPath) return;
|
|
22
|
+
|
|
23
|
+
// eslint-disable-next-line import/no-dynamic-require, global-require
|
|
24
|
+
const event = require(eventPath);
|
|
25
|
+
const prNumber = event.pull_request?.number;
|
|
26
|
+
if (!prNumber) return;
|
|
27
|
+
|
|
28
|
+
const repoFull = process.env.GITHUB_REPOSITORY;
|
|
29
|
+
if (!repoFull || !repoFull.includes('/')) return;
|
|
30
|
+
|
|
31
|
+
const [owner, repo] = repoFull.split('/');
|
|
32
|
+
const octokit = new Octokit({ auth: token });
|
|
33
|
+
|
|
34
|
+
const body = formatPRComment(deployResult, coverageSummary);
|
|
35
|
+
|
|
36
|
+
// Upsert: update existing marker comment if present, else create new
|
|
37
|
+
const existing = await findExistingMarkerComment(octokit, owner, repo, prNumber);
|
|
38
|
+
|
|
39
|
+
if (existing) {
|
|
40
|
+
await octokit.rest.issues.updateComment({
|
|
41
|
+
owner,
|
|
42
|
+
repo,
|
|
43
|
+
comment_id: existing.id,
|
|
44
|
+
body,
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
await octokit.rest.issues.createComment({
|
|
48
|
+
owner,
|
|
49
|
+
repo,
|
|
50
|
+
issue_number: prNumber,
|
|
51
|
+
body,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build a deterministic markdown comment body.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} deployResult
|
|
60
|
+
* @param {any|null} coverageSummary
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
function formatPRComment(deployResult, coverageSummary) {
|
|
64
|
+
const viewUrl = deployResult?.viewUrl;
|
|
65
|
+
const coveragePageUrl = deployResult?.coveragePageUrl || deployResult?.coverageUrl;
|
|
66
|
+
|
|
67
|
+
let body = `${COMMENT_MARKER}
|
|
68
|
+
## Storybook Deployed
|
|
69
|
+
|
|
70
|
+
${viewUrl ? `[View Storybook](${viewUrl})` : 'Storybook deployed successfully.'}`;
|
|
71
|
+
|
|
72
|
+
if (coverageSummary) {
|
|
73
|
+
const qualityGate = coverageSummary.qualityGate;
|
|
74
|
+
const passed = Boolean(qualityGate?.passed);
|
|
75
|
+
const statusIcon = passed ? 'โ
' : 'โ';
|
|
76
|
+
|
|
77
|
+
const m = coverageSummary.summary;
|
|
78
|
+
|
|
79
|
+
body += `
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Coverage Report
|
|
84
|
+
|
|
85
|
+
| Metric | Value |
|
|
86
|
+
|--------|-------|
|
|
87
|
+
| Component Coverage | ${formatPercent(m?.componentCoverage)} |
|
|
88
|
+
| Prop Coverage | ${formatPercent(m?.propCoverage)} |
|
|
89
|
+
| Variant Coverage | ${formatPercent(m?.variantCoverage)} |
|
|
90
|
+
| Pass Rate | ${formatPercent(m?.passRate)} |
|
|
91
|
+
|
|
92
|
+
**Quality Gate:** ${statusIcon} ${passed ? 'PASSED' : 'FAILED'}`;
|
|
93
|
+
|
|
94
|
+
if (coveragePageUrl) {
|
|
95
|
+
body += `
|
|
96
|
+
|
|
97
|
+
[View Coverage Report](${coveragePageUrl})`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return body;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Find a previously-posted comment containing our marker.
|
|
106
|
+
*
|
|
107
|
+
* @param {import('@octokit/rest').Octokit} octokit
|
|
108
|
+
* @param {string} owner
|
|
109
|
+
* @param {string} repo
|
|
110
|
+
* @param {number} prNumber
|
|
111
|
+
*/
|
|
112
|
+
async function findExistingMarkerComment(octokit, owner, repo, prNumber) {
|
|
113
|
+
const { data: comments } = await octokit.rest.issues.listComments({
|
|
114
|
+
owner,
|
|
115
|
+
repo,
|
|
116
|
+
issue_number: prNumber,
|
|
117
|
+
per_page: 100,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return comments.find((c) => typeof c.body === 'string' && c.body.includes(COMMENT_MARKER)) || null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {number|undefined|null} value
|
|
125
|
+
*/
|
|
126
|
+
function formatPercent(value) {
|
|
127
|
+
if (typeof value !== 'number' || Number.isNaN(value)) return 'n/a';
|
|
128
|
+
return `${value.toFixed(1)}%`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
postPRComment,
|
|
133
|
+
formatPRComment,
|
|
134
|
+
findExistingMarkerComment,
|
|
135
|
+
COMMENT_MARKER,
|
|
136
|
+
};
|
package/lib/templates.js
CHANGED
|
@@ -81,7 +81,9 @@ jobs:
|
|
|
81
81
|
steps:
|
|
82
82
|
- name: Checkout code
|
|
83
83
|
uses: actions/checkout@v4
|
|
84
|
-
|
|
84
|
+
with:
|
|
85
|
+
fetch-depth: 0 # Required for coverage new-code analysis
|
|
86
|
+
|
|
85
87
|
${pmSetup} - name: Setup Node.js
|
|
86
88
|
uses: actions/setup-node@v4
|
|
87
89
|
with:
|
|
@@ -95,11 +97,17 @@ ${cache}
|
|
|
95
97
|
run: ${runCmd}
|
|
96
98
|
|
|
97
99
|
- name: Deploy to Scry
|
|
98
|
-
run:
|
|
100
|
+
run: |
|
|
101
|
+
npx @scrymore/scry-deployer \\
|
|
102
|
+
--dir ./storybook-static \\
|
|
103
|
+
\${{ vars.SCRY_COVERAGE_ENABLED == 'false' && '--no-coverage' || '' }} \\
|
|
104
|
+
\${{ vars.SCRY_COVERAGE_FAIL_ON_THRESHOLD == 'true' && '--coverage-fail-on-threshold' || '' }} \\
|
|
105
|
+
--coverage-base \${{ vars.SCRY_COVERAGE_BASE || 'main' }}
|
|
99
106
|
env:
|
|
100
107
|
STORYBOOK_DEPLOYER_API_URL: \${{ vars.SCRY_API_URL }}
|
|
101
108
|
STORYBOOK_DEPLOYER_PROJECT: \${{ vars.SCRY_PROJECT_ID }}
|
|
102
109
|
STORYBOOK_DEPLOYER_API_KEY: \${{ secrets.SCRY_API_KEY }}
|
|
110
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
103
111
|
`;
|
|
104
112
|
}
|
|
105
113
|
|
|
@@ -132,7 +140,9 @@ jobs:
|
|
|
132
140
|
steps:
|
|
133
141
|
- name: Checkout code
|
|
134
142
|
uses: actions/checkout@v4
|
|
135
|
-
|
|
143
|
+
with:
|
|
144
|
+
fetch-depth: 0 # Required for coverage new-code analysis
|
|
145
|
+
|
|
136
146
|
${pmSetup} - name: Setup Node.js
|
|
137
147
|
uses: actions/setup-node@v4
|
|
138
148
|
with:
|
|
@@ -151,8 +161,12 @@ ${cache}
|
|
|
151
161
|
# Deploy with PR-specific version
|
|
152
162
|
npx @scrymore/scry-deployer \\
|
|
153
163
|
--dir ./storybook-static \\
|
|
154
|
-
--version pr-\${{ github.event.pull_request.number }}
|
|
155
|
-
|
|
164
|
+
--version pr-\${{ github.event.pull_request.number }} \\
|
|
165
|
+
\${{ github.event.pull_request.draft == true && '--no-coverage' || '' }} \\
|
|
166
|
+
\${{ vars.SCRY_COVERAGE_ENABLED == 'false' && '--no-coverage' || '' }} \\
|
|
167
|
+
\${{ vars.SCRY_COVERAGE_FAIL_ON_THRESHOLD == 'true' && '--coverage-fail-on-threshold' || '' }} \\
|
|
168
|
+
--coverage-base \${{ vars.SCRY_COVERAGE_BASE || 'main' }}
|
|
169
|
+
|
|
156
170
|
# Construct deployment URL using VIEW_URL (where users access the deployed Storybook)
|
|
157
171
|
# Defaults to https://view.scrymore.com if SCRY_VIEW_URL is not set
|
|
158
172
|
PROJECT_ID="\${{ vars.SCRY_PROJECT_ID }}"
|
|
@@ -163,61 +177,7 @@ ${cache}
|
|
|
163
177
|
STORYBOOK_DEPLOYER_API_URL: \${{ vars.SCRY_API_URL }}
|
|
164
178
|
STORYBOOK_DEPLOYER_PROJECT: \${{ vars.SCRY_PROJECT_ID }}
|
|
165
179
|
STORYBOOK_DEPLOYER_API_KEY: \${{ secrets.SCRY_API_KEY }}
|
|
166
|
-
|
|
167
|
-
- name: Comment on PR
|
|
168
|
-
uses: actions/github-script@v7
|
|
169
|
-
with:
|
|
170
|
-
script: |
|
|
171
|
-
const deploymentUrl = '\${{ steps.deploy.outputs.deployment_url }}';
|
|
172
|
-
const prNumber = context.issue.number;
|
|
173
|
-
const commitSha = context.payload.pull_request.head.sha.substring(0, 7);
|
|
174
|
-
const branch = '\${{ github.event.pull_request.head.ref }}';
|
|
175
|
-
const deployedAt = new Date().toUTCString();
|
|
176
|
-
|
|
177
|
-
const commentBody = [
|
|
178
|
-
'## ๐ Storybook Preview Deployed',
|
|
179
|
-
'',
|
|
180
|
-
'**Preview URL:** ' + deploymentUrl,
|
|
181
|
-
'',
|
|
182
|
-
'๐ **Details:**',
|
|
183
|
-
'- **Commit:** ' + commitSha,
|
|
184
|
-
'- **Branch:** ' + branch,
|
|
185
|
-
'- **Deployed at:** ' + deployedAt,
|
|
186
|
-
'',
|
|
187
|
-
'> This preview updates automatically on each commit to this PR.'
|
|
188
|
-
].join('\\n');
|
|
189
|
-
|
|
190
|
-
// Check if a comment from this bot already exists
|
|
191
|
-
const { data: comments } = await github.rest.issues.listComments({
|
|
192
|
-
owner: context.repo.owner,
|
|
193
|
-
repo: context.repo.repo,
|
|
194
|
-
issue_number: prNumber,
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
const botComment = comments.find(comment =>
|
|
198
|
-
comment.user.type === 'Bot' &&
|
|
199
|
-
comment.body.includes('๐ Storybook Preview Deployed')
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
if (botComment) {
|
|
203
|
-
// Update existing comment
|
|
204
|
-
await github.rest.issues.updateComment({
|
|
205
|
-
owner: context.repo.owner,
|
|
206
|
-
repo: context.repo.repo,
|
|
207
|
-
comment_id: botComment.id,
|
|
208
|
-
body: commentBody
|
|
209
|
-
});
|
|
210
|
-
console.log('Updated existing PR comment');
|
|
211
|
-
} else {
|
|
212
|
-
// Create new comment
|
|
213
|
-
await github.rest.issues.createComment({
|
|
214
|
-
owner: context.repo.owner,
|
|
215
|
-
repo: context.repo.repo,
|
|
216
|
-
issue_number: prNumber,
|
|
217
|
-
body: commentBody
|
|
218
|
-
});
|
|
219
|
-
console.log('Created new PR comment');
|
|
220
|
-
}
|
|
180
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
221
181
|
`;
|
|
222
182
|
}
|
|
223
183
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scrymore/scry-deployer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "A CLI to automate the deployment of Storybook static builds.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
"node": ">=18"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"@octokit/rest": "^20.0.0",
|
|
40
|
+
"@scrymore/scry-sbcov": "^0.2.1",
|
|
41
|
+
"@sentry/node": "^10.33.0",
|
|
39
42
|
"archiver": "^7.0.1",
|
|
40
43
|
"axios": "^1.12.2",
|
|
41
44
|
"chalk": "^4.1.2",
|
|
@@ -49,10 +52,13 @@
|
|
|
49
52
|
"devDependencies": {
|
|
50
53
|
"@changesets/changelog-github": "^0.5.2",
|
|
51
54
|
"@changesets/cli": "^2.29.8",
|
|
52
|
-
"dotenv": "^17.2.2"
|
|
55
|
+
"dotenv": "^17.2.2",
|
|
56
|
+
"jest": "^29.7.0"
|
|
53
57
|
},
|
|
54
58
|
"scripts": {
|
|
55
|
-
"test": "
|
|
59
|
+
"test": "jest",
|
|
60
|
+
"test:watch": "jest --watch",
|
|
61
|
+
"test:coverage": "jest --coverage",
|
|
56
62
|
"postinstall": "node scripts/postinstall.js",
|
|
57
63
|
"changeset": "changeset",
|
|
58
64
|
"version": "changeset version",
|