@scrymore/scry-deployer 0.12.4 → 0.13.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 +11 -0
- package/bin/cli.js +11 -1
- package/lib/apiClient.js +46 -16
- package/lib/coverage.js +8 -0
- package/lib/deltaUpload.js +514 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,6 +22,17 @@ or CI secret store.
|
|
|
22
22
|
See the [setup guide](https://docs.scrymore.com/guide/skill) for installation
|
|
23
23
|
options, or inspect the [skill instructions](skills/scry-setup/SKILL.md).
|
|
24
24
|
|
|
25
|
+
### Use your latest Scry Snip
|
|
26
|
+
|
|
27
|
+
If you take snips with Scry Sync, install the `ss` skill and type `/ss` (or
|
|
28
|
+
`/ss fix this`) to hand your newest snip to the assistant:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx skills add epinnock/scry-node --skill ss
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
It needs the Scry MCP connection that `scry-setup` configures.
|
|
35
|
+
|
|
25
36
|
## Set up directly with the CLI
|
|
26
37
|
|
|
27
38
|
`init` writes configuration and workflows, configures GitHub secrets, then
|
package/bin/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ const path = require('path');
|
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const { zipDirectory } = require('../lib/archive.js');
|
|
10
10
|
const { getApiClient, uploadBuild, sanitizeServerText } = require('../lib/apiClient.js');
|
|
11
|
+
const { removeDeltaBundle } = require('../lib/deltaUpload.js');
|
|
11
12
|
const { createLogger } = require('../lib/logger.js');
|
|
12
13
|
const { ApiError, UserInputError } = require('../lib/errors.js');
|
|
13
14
|
const { loadConfig } = require('../lib/config.js');
|
|
@@ -482,6 +483,15 @@ async function runDeployment(argv) {
|
|
|
482
483
|
// uploads the metadata ZIP (legacy path, unchanged keys), so the sibling is only removed.
|
|
483
484
|
const scfSibling = metadataZipPath ? metadataZipPath.replace(/\.zip$/i, '') + '.scf.zip' : null;
|
|
484
485
|
if (scfSibling && fs.existsSync(scfSibling)) fs.unlinkSync(scfSibling);
|
|
486
|
+
// The delta bundle (manifest + picture copies) sbcov wrote for the manifest-first upload.
|
|
487
|
+
if (metadataZipPath) {
|
|
488
|
+
removeDeltaBundle(metadataZipPath);
|
|
489
|
+
// The ZIP lives in a private directory of its own (see runCoverage); drop it once empty.
|
|
490
|
+
const zipDir = path.dirname(metadataZipPath);
|
|
491
|
+
if (path.basename(zipDir).startsWith('scry-metadata-')) {
|
|
492
|
+
try { fs.rmdirSync(zipDir); } catch { /* not empty or already gone */ }
|
|
493
|
+
}
|
|
494
|
+
}
|
|
485
495
|
}
|
|
486
496
|
}
|
|
487
497
|
|
|
@@ -1226,7 +1236,7 @@ async function resolveCoverage(argv, logger) {
|
|
|
1226
1236
|
} else {
|
|
1227
1237
|
const needsScreenshots = Boolean(argv.withAnalysis);
|
|
1228
1238
|
const outputZipPath = needsScreenshots
|
|
1229
|
-
? path.join(os.tmpdir(),
|
|
1239
|
+
? path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'scry-metadata-')), 'metadata.zip')
|
|
1230
1240
|
: null;
|
|
1231
1241
|
|
|
1232
1242
|
const result = await runCoverageAnalysis({
|
package/lib/apiClient.js
CHANGED
|
@@ -3,6 +3,7 @@ const fs = require('fs');
|
|
|
3
3
|
const { ApiError } = require('./errors.js');
|
|
4
4
|
const { createLogger } = require('./logger.js');
|
|
5
5
|
const { version: PACKAGE_VERSION } = require('../package.json');
|
|
6
|
+
const { uploadViaDelta } = require('./deltaUpload.js');
|
|
6
7
|
|
|
7
8
|
// Every Scry service echoes an x-scry-request-id; showing it as "Ref: <id>"
|
|
8
9
|
// lets a user quote it and support find the whole server-side trail.
|
|
@@ -177,9 +178,12 @@ function describeUploadError(error) {
|
|
|
177
178
|
* @param {axios.AxiosInstance} apiClient
|
|
178
179
|
* @param {string} url
|
|
179
180
|
* @param {Buffer|string|object} body
|
|
180
|
-
* @param {{label:string, bytes:number, headers:object, log?:{info:Function}}} o
|
|
181
|
+
* @param {{label:string, bytes:number, headers:object, maxRedirects?:number, log?:{info:Function}}} o
|
|
182
|
+
* `maxRedirects`: when set, overrides the client's default (5). The shared client sends `X-API-Key`, and
|
|
183
|
+
* follow-redirects strips only Authorization/Cookie on a cross-host hop, so a call that never expects a
|
|
184
|
+
* redirect passes 0 and a 3xx surfaces as an error instead of carrying the key to another host.
|
|
181
185
|
*/
|
|
182
|
-
async function postThroughService(apiClient, url, body, { label, bytes, headers, log = logger }) {
|
|
186
|
+
async function postThroughService(apiClient, url, body, { label, bytes, headers, maxRedirects, log = logger }) {
|
|
183
187
|
const timeout = uploadTimeoutMs(bytes);
|
|
184
188
|
const backoffRaw = Number(process.env.SCRY_UPLOAD_BACKOFF_MS);
|
|
185
189
|
const backoffBase = Number.isFinite(backoffRaw) && backoffRaw >= 0 ? backoffRaw : UPLOAD_BACKOFF_BASE_MS;
|
|
@@ -194,6 +198,7 @@ async function postThroughService(apiClient, url, body, { label, bytes, headers,
|
|
|
194
198
|
timeout,
|
|
195
199
|
maxContentLength: 100 * 1024 * 1024,
|
|
196
200
|
maxBodyLength: 100 * 1024 * 1024,
|
|
201
|
+
...(maxRedirects === undefined ? {} : { maxRedirects }),
|
|
197
202
|
});
|
|
198
203
|
log.info(`${label}: sent ${size} in ${((Date.now() - started) / 1000).toFixed(1)} s (attempt ${attempt}/${SERVICE_UPLOAD_MAX_ATTEMPTS}).`);
|
|
199
204
|
return response;
|
|
@@ -698,6 +703,43 @@ async function failMetadataUpload(apiClient, target, stage, error, customLogger)
|
|
|
698
703
|
return { success: false, error: message, requestId: getRequestId(error), markedFailed, buildId: stage.buildId };
|
|
699
704
|
}
|
|
700
705
|
|
|
706
|
+
/**
|
|
707
|
+
* The manifest-first (delta) upload of the metadata part of a deploy; see lib/deltaUpload.js.
|
|
708
|
+
* `handled: false` + a reason means: use the metadata ZIP path.
|
|
709
|
+
*
|
|
710
|
+
* @param {{commitSha?: string, branch?: string}} [gitContext] The provenance uploadMetadataZip sends.
|
|
711
|
+
* @param {{buildId?: string}} [known] The build the storybook ZIP created; the pictures attach to it.
|
|
712
|
+
* Without one the delta path is not used.
|
|
713
|
+
*/
|
|
714
|
+
function uploadMetadataDelta(apiClient, target, metadataZipPath, customLogger = logger, gitContext = {}, known = {}) {
|
|
715
|
+
return uploadViaDelta(apiClient, target, metadataZipPath, {
|
|
716
|
+
postThroughService,
|
|
717
|
+
markMetadataUploadFailed,
|
|
718
|
+
uploadTimeoutMs,
|
|
719
|
+
isTransientUploadError,
|
|
720
|
+
formatBytes,
|
|
721
|
+
log: customLogger,
|
|
722
|
+
}, { buildId: known.buildId, gitContext });
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* The metadata + screenshots part of a deploy. Manifest-first: send only the pictures the
|
|
727
|
+
* project does not hold. Anything that stops it before the commit falls back to the metadata
|
|
728
|
+
* ZIP, with the reason on one line; a commit that says pictures are missing ends the deploy
|
|
729
|
+
* red (never green, G6).
|
|
730
|
+
*/
|
|
731
|
+
async function sendMetadata(apiClient, target, options, zipUpload) {
|
|
732
|
+
// gitContext is passed in rather than resolved here: the caller knows the repository the
|
|
733
|
+
// deploy is for, and a helper that shells out to git on its own would do so in every test
|
|
734
|
+
// that touches this path. The delta and the ZIP get the same provenance and the same build.
|
|
735
|
+
const gitContext = options.gitContext || {};
|
|
736
|
+
const known = { buildId: zipUpload?.buildId };
|
|
737
|
+
const delta = await uploadMetadataDelta(apiClient, target, options.metadataZipPath, logger, gitContext, known);
|
|
738
|
+
if (delta.handled) return delta.result;
|
|
739
|
+
logger.info(`Delta upload not used (${delta.reason}); sending the metadata ZIP.`);
|
|
740
|
+
return uploadMetadataZip(apiClient, target, options.metadataZipPath, logger, gitContext, known);
|
|
741
|
+
}
|
|
742
|
+
|
|
701
743
|
/**
|
|
702
744
|
* Upload storybook zip plus optional coverage report.
|
|
703
745
|
*
|
|
@@ -754,20 +796,7 @@ async function uploadBuild(apiClient, target, options) {
|
|
|
754
796
|
}
|
|
755
797
|
}
|
|
756
798
|
|
|
757
|
-
|
|
758
|
-
if (options.metadataZipPath) {
|
|
759
|
-
// Passed in rather than resolved here: the caller knows the repository the
|
|
760
|
-
// deploy is for, and a helper that shells out to git on its own would do so
|
|
761
|
-
// in every test that touches this path.
|
|
762
|
-
metadataUpload = await uploadMetadataZip(
|
|
763
|
-
apiClient,
|
|
764
|
-
target,
|
|
765
|
-
options.metadataZipPath,
|
|
766
|
-
logger,
|
|
767
|
-
options.gitContext || {},
|
|
768
|
-
{ buildId: zipUpload?.buildId },
|
|
769
|
-
);
|
|
770
|
-
}
|
|
799
|
+
const metadataUpload = options.metadataZipPath ? await sendMetadata(apiClient, target, options, zipUpload) : null;
|
|
771
800
|
|
|
772
801
|
return { zipUpload, coverageUpload, metadataUpload };
|
|
773
802
|
}
|
|
@@ -863,6 +892,7 @@ module.exports = {
|
|
|
863
892
|
uploadFileDirectly,
|
|
864
893
|
uploadCoverageReportDirectly,
|
|
865
894
|
uploadMetadataZip,
|
|
895
|
+
uploadMetadataDelta,
|
|
866
896
|
uploadBuild,
|
|
867
897
|
requestPresignedUrl,
|
|
868
898
|
putToPresignedUrl,
|
package/lib/coverage.js
CHANGED
|
@@ -3,6 +3,7 @@ const fs = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const chalk = require('chalk');
|
|
5
5
|
const { startTimer } = require('./ciTimings.js');
|
|
6
|
+
const { deltaEnabled } = require('./deltaUpload.js');
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* @typedef {Object} RunCoverageOptions
|
|
@@ -238,6 +239,13 @@ async function runCoverageAnalysis(options) {
|
|
|
238
239
|
}
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
// --delta-manifest (sbcov 0.9+): also write the delta bundle (manifest + pictures) beside the
|
|
243
|
+
// metadata ZIP for the manifest-first upload. sbcov writes it by default, so with SCRY_DELTA=0
|
|
244
|
+
// (the ZIP is all that is sent) ask it not to. Only to an sbcov that lists the flag.
|
|
245
|
+
if (screenshots && outputZipPath && sbcovSupportsFlag(sbcovCommandPrefix, '--delta-manifest')) {
|
|
246
|
+
cliArgs.push(deltaEnabled() ? '--delta-manifest' : '--no-delta-manifest');
|
|
247
|
+
}
|
|
248
|
+
|
|
241
249
|
const npxCommand = `${sbcovCommandPrefix} ${cliArgs.map(shellEscape).join(' ')}`;
|
|
242
250
|
|
|
243
251
|
// Debug logging to show the exact command being executed
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Manifest-first upload of a deploy's pictures (feature content-hash-identity, slice 2, PR 6).
|
|
5
|
+
*
|
|
6
|
+
* POST /delta/{project}/manifest open a build; the answer says which pictures the project does not hold
|
|
7
|
+
* PUT <actions.upload.href> only those, 8 at a time, href and header exactly as given
|
|
8
|
+
* POST /delta/{project}/builds/{id}/commit every picture is held -> the build is queued
|
|
9
|
+
*
|
|
10
|
+
* It replaces the metadata+screenshots ZIP when sbcov wrote a delta bundle (`delta-manifest.json`
|
|
11
|
+
* and the picture files). Anything that stops the delta path BEFORE the commit falls back to the
|
|
12
|
+
* ZIP path with a one-line reason (the caller prints it). What is never a fallback, because the
|
|
13
|
+
* ZIP would hide it: the upload service saying at commit that a picture it was told about did not
|
|
14
|
+
* arrive (409 missing_blobs) or that the key may not commit this build (403). Those end the deploy
|
|
15
|
+
* red with the request id (guarantee G6: never green with pictures missing).
|
|
16
|
+
*
|
|
17
|
+
* One deploy is one build: the manifest names the build the deploy's storybook.zip upload already
|
|
18
|
+
* created (`buildId`) and carries the same git provenance the metadata-ZIP path sends, so the
|
|
19
|
+
* pictures attach to that build instead of opening a second one. Without that build id the delta
|
|
20
|
+
* path is not used (the ZIP is). A hard failure marks that build failed, as the ZIP path does.
|
|
21
|
+
*
|
|
22
|
+
* Secrets: the Scry API key goes only to the API's own origin. A foreign `href` (the direct-to-R2
|
|
23
|
+
* link) is followed as given with exactly the header the server named and nothing else, and the
|
|
24
|
+
* href itself (it carries a signature) is never printed.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const axios = require('axios');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
|
|
32
|
+
const PROTOCOL_VERSION = 1;
|
|
33
|
+
const HASH_ALGORITHM = 'sha256';
|
|
34
|
+
const DELTA_MANIFEST_FILE = 'delta-manifest.json';
|
|
35
|
+
const DELTA_FILES_DIR = 'delta-files';
|
|
36
|
+
const STALE_SLACK_MS = 2000;
|
|
37
|
+
/** Highest-preference first: R2 direct links (a foreign origin), then the upload service itself. */
|
|
38
|
+
const TRANSFERS = ['r2-presigned', 'basic'];
|
|
39
|
+
const PUT_CONCURRENCY = 8;
|
|
40
|
+
/** A PUT is tried once plus this many retries (5xx, 429 and network errors only). */
|
|
41
|
+
const PUT_RETRIES = 3;
|
|
42
|
+
const PUT_BACKOFF_BASE_MS = 500;
|
|
43
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
44
|
+
const REQUEST_ID_RE = /^[0-9A-Za-z-]{8,64}$/;
|
|
45
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
46
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
47
|
+
const UNSAFE_TEXT_RE = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* SCRY_DELTA=0 turns the delta path off (the ZIP path is used). Anything else, including unset, is on.
|
|
51
|
+
*
|
|
52
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
53
|
+
*/
|
|
54
|
+
function deltaEnabled(env = process.env) {
|
|
55
|
+
return String(env.SCRY_DELTA ?? '').trim() !== '0';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where sbcov (--delta-manifest) put the delta bundle: next to the metadata ZIP, `delta-manifest.json`
|
|
60
|
+
* (written last, atomically) and `delta-files/` holding each picture at `delta-files/<manifest path>`
|
|
61
|
+
* (e.g. `delta-files/images/<name>.png`).
|
|
62
|
+
*
|
|
63
|
+
* @param {string} metadataZipPath
|
|
64
|
+
* @returns {{manifestPath: string, root: string}|null} null when sbcov wrote none
|
|
65
|
+
*/
|
|
66
|
+
function locateDeltaBundle(metadataZipPath) {
|
|
67
|
+
if (!metadataZipPath) return null;
|
|
68
|
+
const dir = path.dirname(metadataZipPath);
|
|
69
|
+
const manifestPath = path.join(dir, DELTA_MANIFEST_FILE);
|
|
70
|
+
return fs.existsSync(manifestPath) ? { manifestPath, root: path.join(dir, DELTA_FILES_DIR) } : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True when the manifest is clearly older than the ZIP (left over from another run in a shared
|
|
75
|
+
* directory). sbcov writes the manifest after the ZIP, so a fresh one is never older.
|
|
76
|
+
*/
|
|
77
|
+
function isStaleBundle(bundle, metadataZipPath) {
|
|
78
|
+
try {
|
|
79
|
+
const zipTime = fs.statSync(metadataZipPath).mtimeMs;
|
|
80
|
+
return fs.statSync(bundle.manifestPath).mtimeMs < zipTime - STALE_SLACK_MS;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Remove what sbcov wrote for the delta upload (the manifest and the picture copies), nothing else. */
|
|
87
|
+
function removeDeltaBundle(metadataZipPath) {
|
|
88
|
+
if (!metadataZipPath) return;
|
|
89
|
+
const dir = path.dirname(metadataZipPath);
|
|
90
|
+
fs.rmSync(path.join(dir, DELTA_MANIFEST_FILE), { force: true });
|
|
91
|
+
fs.rmSync(path.join(dir, DELTA_FILES_DIR), { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const safeText = (value, max = 200) => String(value ?? '').replace(UNSAFE_TEXT_RE, ' ').slice(0, max);
|
|
95
|
+
|
|
96
|
+
/** The request id of an axios error's response (header, else the body's request_id), if well formed. */
|
|
97
|
+
function requestIdOf(error) {
|
|
98
|
+
const headers = error?.response?.headers;
|
|
99
|
+
const raw = headers && (typeof headers.get === 'function' ? headers.get('x-scry-request-id') : headers['x-scry-request-id']);
|
|
100
|
+
const candidates = [raw, error?.response?.data?.request_id];
|
|
101
|
+
for (const c of candidates) {
|
|
102
|
+
const id = typeof c === 'string' ? c.trim() : '';
|
|
103
|
+
if (REQUEST_ID_RE.test(id)) return id;
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const codeOf = (error) => {
|
|
109
|
+
const data = error?.response?.data;
|
|
110
|
+
return data && typeof data === 'object' && typeof data.error === 'string' ? safeText(data.error, 64) : undefined;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function describe(error) {
|
|
114
|
+
const status = error?.response?.status;
|
|
115
|
+
const code = codeOf(error);
|
|
116
|
+
if (status) return code ? `HTTP ${status} ${code}` : `HTTP ${status}`;
|
|
117
|
+
return safeText(error?.code || error?.message || 'unknown error', 80);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const sha256Hex = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
|
121
|
+
|
|
122
|
+
function sha256OfFile(file) {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const hash = crypto.createHash('sha256');
|
|
125
|
+
const stream = fs.createReadStream(file);
|
|
126
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
127
|
+
stream.on('error', reject);
|
|
128
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Thrown inside this module to leave the delta path: `fallback` = send the ZIP, else the deploy fails. */
|
|
133
|
+
class DeltaStop extends Error {
|
|
134
|
+
constructor(kind, message, extra = {}) {
|
|
135
|
+
super(message);
|
|
136
|
+
this.kind = kind; // 'fallback' | 'fail'
|
|
137
|
+
this.extra = extra;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const fallback = (reason) => new DeltaStop('fallback', reason);
|
|
141
|
+
const fail = (message, extra) => new DeltaStop('fail', message, extra);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Read and check the bundle on disk: the manifest parses, every picture is a regular file inside
|
|
145
|
+
* the bundle whose size and sha256 are what the manifest says (an sbcov bug must not reach the
|
|
146
|
+
* server as a wrong oid). Throws a fallback for anything unusable.
|
|
147
|
+
*
|
|
148
|
+
* @param {{buildId: string, gitContext: {commitSha?: string, branch?: string}}} attach
|
|
149
|
+
* @returns {Promise<{body: object, files: Map<string, {file: string, size: number}>, entries: number}>}
|
|
150
|
+
*/
|
|
151
|
+
async function readBundle(bundle, target, attach) {
|
|
152
|
+
const manifest = readManifestFile(bundle.manifestPath);
|
|
153
|
+
const root = path.resolve(bundle.root);
|
|
154
|
+
/** @type {Map<string, {file: string, size: number}>} one entry per distinct picture (oid) */
|
|
155
|
+
const files = new Map();
|
|
156
|
+
for (const [name, entry] of Object.entries(manifest.images)) {
|
|
157
|
+
if (!isImageEntry(entry)) throw fallback('the delta manifest has a picture entry that is not {oid, size}');
|
|
158
|
+
if (!files.has(entry.oid)) files.set(entry.oid, await verifyPicture(root, name, entry));
|
|
159
|
+
}
|
|
160
|
+
if (files.size === 0) throw fallback('the delta manifest lists no pictures');
|
|
161
|
+
// The deploy's own version wins over whatever label sbcov wrote.
|
|
162
|
+
const body = {
|
|
163
|
+
...manifest,
|
|
164
|
+
protocol: PROTOCOL_VERSION,
|
|
165
|
+
hash: HASH_ALGORITHM,
|
|
166
|
+
version: target.version || manifest.version || 'latest',
|
|
167
|
+
// The storybook.zip build this deploy already has, and the same provenance the metadata ZIP carries
|
|
168
|
+
// (members that are not known are left out). Both are part of the body, so of the Idempotency-Key.
|
|
169
|
+
buildId: attach.buildId,
|
|
170
|
+
...(attach.gitContext.commitSha ? { commitSha: attach.gitContext.commitSha } : {}),
|
|
171
|
+
...(attach.gitContext.branch ? { branch: attach.gitContext.branch } : {}),
|
|
172
|
+
transfers: TRANSFERS,
|
|
173
|
+
};
|
|
174
|
+
return { body, files, entries: Object.keys(manifest.images).length };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readManifestFile(manifestPath) {
|
|
178
|
+
let manifest;
|
|
179
|
+
try {
|
|
180
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
181
|
+
} catch {
|
|
182
|
+
throw fallback('the delta manifest sbcov wrote cannot be read');
|
|
183
|
+
}
|
|
184
|
+
const images = manifest && typeof manifest === 'object' ? manifest.images : null;
|
|
185
|
+
const wellFormed = images && typeof images === 'object' && !Array.isArray(images) && manifest.scf && typeof manifest.source === 'string';
|
|
186
|
+
if (!wellFormed) throw fallback('the delta manifest sbcov wrote is not the expected shape');
|
|
187
|
+
return manifest;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isImageEntry(entry) {
|
|
191
|
+
const { oid, size: bytes } = entry || {};
|
|
192
|
+
return typeof oid === 'string' && SHA256_HEX.test(oid) && Number.isInteger(bytes) && bytes >= 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** One picture of the bundle: inside the root, a regular file, the size and sha256 the manifest says. */
|
|
196
|
+
async function verifyPicture(root, name, entry) {
|
|
197
|
+
const file = path.resolve(root, name);
|
|
198
|
+
if (!file.startsWith(root + path.sep)) throw fallback('the delta manifest names a picture outside its bundle');
|
|
199
|
+
let stat;
|
|
200
|
+
try {
|
|
201
|
+
stat = fs.lstatSync(file);
|
|
202
|
+
} catch {
|
|
203
|
+
throw fallback('a picture named in the delta manifest is missing on disk');
|
|
204
|
+
}
|
|
205
|
+
if (!stat.isFile() || stat.size !== entry.size) throw fallback('a picture on disk does not match its delta manifest size');
|
|
206
|
+
if ((await sha256OfFile(file)) !== entry.oid) throw fallback('a picture on disk does not match its delta manifest oid');
|
|
207
|
+
return { file, size: stat.size };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The API key and base URL of an axios instance made by `getApiClient`. */
|
|
211
|
+
function apiIdentity(apiClient) {
|
|
212
|
+
const base = apiClient?.defaults?.baseURL;
|
|
213
|
+
const headers = apiClient?.defaults?.headers || {};
|
|
214
|
+
const key = headers['X-API-Key'] ?? headers.common?.['X-API-Key'];
|
|
215
|
+
return { base: typeof base === 'string' ? base : '', key: typeof key === 'string' ? key : '' };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Whether an upload `href` may be followed, and whether the Scry key may go with it.
|
|
220
|
+
* @returns {{sameOrigin: boolean}} throws a fallback when the href cannot be followed
|
|
221
|
+
*/
|
|
222
|
+
function checkHref(href, apiBase) {
|
|
223
|
+
let url;
|
|
224
|
+
try {
|
|
225
|
+
url = new URL(href);
|
|
226
|
+
} catch {
|
|
227
|
+
throw fallback('the upload service sent an upload address that cannot be used');
|
|
228
|
+
}
|
|
229
|
+
let api = null;
|
|
230
|
+
try {
|
|
231
|
+
api = apiBase ? new URL(apiBase) : null;
|
|
232
|
+
} catch {
|
|
233
|
+
api = null;
|
|
234
|
+
}
|
|
235
|
+
if (api && url.origin === api.origin) return { sameOrigin: true };
|
|
236
|
+
// A foreign address is where the picture goes (the direct-to-storage link); it must be encrypted
|
|
237
|
+
// in transit unless it is this machine.
|
|
238
|
+
if (url.protocol !== 'https:' && !LOOPBACK_HOSTS.has(url.hostname)) {
|
|
239
|
+
throw fallback('the upload service sent a non-https upload address on another host');
|
|
240
|
+
}
|
|
241
|
+
return { sameOrigin: false };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
245
|
+
|
|
246
|
+
function backoffBaseMs(env = process.env) {
|
|
247
|
+
const raw = Number(env.SCRY_UPLOAD_BACKOFF_MS);
|
|
248
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : PUT_BACKOFF_BASE_MS;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* PUT one picture to the address the server named. Retried up to PUT_RETRIES times on 5xx, 429
|
|
253
|
+
* and network errors. Returns bytes on success; throws the last error otherwise.
|
|
254
|
+
*/
|
|
255
|
+
async function putPicture({ action, picture, identity, uploadTimeoutMs, isTransient, log }) {
|
|
256
|
+
const { sameOrigin } = checkHref(action.href, identity.base);
|
|
257
|
+
const headers = {};
|
|
258
|
+
for (const [name, value] of Object.entries(action.header || {})) {
|
|
259
|
+
if (typeof value === 'string') headers[name] = value;
|
|
260
|
+
}
|
|
261
|
+
// The server's header map cannot set authentication; the key is ours to add, to our own origin only.
|
|
262
|
+
if (sameOrigin && identity.key) headers['X-API-Key'] = identity.key;
|
|
263
|
+
let lastError;
|
|
264
|
+
for (let attempt = 1; attempt <= PUT_RETRIES + 1; attempt++) {
|
|
265
|
+
try {
|
|
266
|
+
const data = fs.readFileSync(picture.file);
|
|
267
|
+
await axios.put(action.href, data, {
|
|
268
|
+
headers,
|
|
269
|
+
timeout: uploadTimeoutMs(picture.size),
|
|
270
|
+
maxContentLength: Infinity,
|
|
271
|
+
maxBodyLength: Infinity,
|
|
272
|
+
// A redirect would carry the key (or the signature) to a host nobody chose.
|
|
273
|
+
maxRedirects: 0,
|
|
274
|
+
// Axios would add a Content-Type; the server named exactly the headers it signed.
|
|
275
|
+
transformRequest: [(d) => d],
|
|
276
|
+
});
|
|
277
|
+
return picture.size;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
lastError = error;
|
|
280
|
+
if (!isTransient(error) || attempt === PUT_RETRIES + 1) break;
|
|
281
|
+
const wait = backoffBaseMs() * 2 ** (attempt - 1);
|
|
282
|
+
log.info(`Delta picture upload attempt ${attempt}/${PUT_RETRIES + 1} failed (${describe(error)}); retrying in ${wait / 1000} s...`);
|
|
283
|
+
await sleep(wait);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
throw lastError;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Run `worker(item)` over `items`, at most `limit` at once; stops handing out work after the first failure. */
|
|
290
|
+
async function runPool(items, limit, worker) {
|
|
291
|
+
let next = 0;
|
|
292
|
+
let failure = null;
|
|
293
|
+
const lanes = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
294
|
+
while (!failure && next < items.length) {
|
|
295
|
+
const item = items[next++];
|
|
296
|
+
try {
|
|
297
|
+
await worker(item);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
failure = failure || error;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
await Promise.all(lanes);
|
|
304
|
+
if (failure) throw failure;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** A manifest request that failed for good: fall back, except for answers the ZIP path would hide. */
|
|
308
|
+
function stopForManifestError(error) {
|
|
309
|
+
const status = error?.response?.status;
|
|
310
|
+
const code = codeOf(error);
|
|
311
|
+
// The build this deploy created cannot take a delta (not an untouched Storybook build of this project).
|
|
312
|
+
// Nothing was committed, so the metadata ZIP goes to that same build.
|
|
313
|
+
if (status === 409 && code === 'build_not_attachable') {
|
|
314
|
+
return fallback('the upload service cannot attach pictures to this build (build_not_attachable)');
|
|
315
|
+
}
|
|
316
|
+
if (status === 401 || status === 403 || status === 409) {
|
|
317
|
+
return fail(`the upload service refused the delta manifest (${describe(error)})`, { requestId: requestIdOf(error) });
|
|
318
|
+
}
|
|
319
|
+
if (status === 404) return fallback('the upload service has no delta route (HTTP 404)');
|
|
320
|
+
if (status === 503 && code === 'delta_disabled') return fallback('the upload service has delta upload switched off (delta_disabled)');
|
|
321
|
+
return fallback(`the delta manifest request failed (${describe(error)})`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Read `objects` from the manifest answer into oid -> {upload action | error}. */
|
|
325
|
+
function readObjects(data) {
|
|
326
|
+
if (!data || typeof data.buildId !== 'string' || !Array.isArray(data.objects)) {
|
|
327
|
+
throw fallback('the upload service sent a manifest answer that cannot be read');
|
|
328
|
+
}
|
|
329
|
+
const byOid = new Map();
|
|
330
|
+
for (const o of data.objects) {
|
|
331
|
+
if (!o || typeof o.oid !== 'string') continue;
|
|
332
|
+
const upload = o.actions && o.actions.upload;
|
|
333
|
+
byOid.set(o.oid, {
|
|
334
|
+
upload: upload && typeof upload.href === 'string' ? { href: upload.href, header: upload.header || {} } : null,
|
|
335
|
+
error: o.error && typeof o.error.code === 'string' ? safeText(o.error.code, 64) : null,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return byOid;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Upload the pictures of a deploy through the delta protocol.
|
|
343
|
+
*
|
|
344
|
+
* @param {import('axios').AxiosInstance} apiClient made by getApiClient (baseURL + X-API-Key)
|
|
345
|
+
* @param {{project: string, version: string}} target
|
|
346
|
+
* @param {string} metadataZipPath where sbcov's metadata ZIP is; the delta bundle sits beside it
|
|
347
|
+
* @param {object} deps from apiClient.js: postThroughService, markMetadataUploadFailed, uploadTimeoutMs, isTransientUploadError, formatBytes, log
|
|
348
|
+
* @param {{env?: NodeJS.ProcessEnv, buildId?: string, gitContext?: {commitSha?: string, branch?: string}}} [options]
|
|
349
|
+
* buildId: the build the deploy's storybook.zip upload created (the pictures attach to it); gitContext: the provenance
|
|
350
|
+
* the metadata-ZIP path sends.
|
|
351
|
+
* @returns {Promise<{handled: false, reason: string} | {handled: true, result: object}>}
|
|
352
|
+
* handled=false: use the ZIP path (reason is one line). handled=true: result has the shape of
|
|
353
|
+
* an uploadMetadataZip result (success true, or success false with error/requestId/markedFailed).
|
|
354
|
+
*/
|
|
355
|
+
async function uploadViaDelta(apiClient, target, metadataZipPath, deps, options = {}) {
|
|
356
|
+
const env = options.env || process.env;
|
|
357
|
+
if (!deltaEnabled(env)) return { handled: false, reason: 'SCRY_DELTA=0' };
|
|
358
|
+
const bundle = locateDeltaBundle(metadataZipPath);
|
|
359
|
+
if (!bundle) return { handled: false, reason: 'sbcov wrote no delta manifest' };
|
|
360
|
+
if (isStaleBundle(bundle, metadataZipPath)) return { handled: false, reason: 'the delta manifest is older than the metadata ZIP' };
|
|
361
|
+
// One deploy is one build: without the storybook.zip build to attach to, the delta would open a second one.
|
|
362
|
+
const buildId = typeof options.buildId === 'string' ? options.buildId : '';
|
|
363
|
+
if (!buildId) return { handled: false, reason: 'the storybook.zip upload returned no build id to attach the pictures to' };
|
|
364
|
+
const attach = { buildId, gitContext: options.gitContext || {} };
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
return { handled: true, result: await runDelta(apiClient, target, bundle, deps, attach) };
|
|
368
|
+
} catch (error) {
|
|
369
|
+
if (!(error instanceof DeltaStop)) throw error;
|
|
370
|
+
if (error.kind === 'fallback') return { handled: false, reason: error.message };
|
|
371
|
+
deps.log.error(`Delta upload failed: ${error.message}`);
|
|
372
|
+
// As the ZIP path does (failMetadataUpload): the build the dashboard shows must not stay "pending".
|
|
373
|
+
const markedFailed = typeof deps.markMetadataUploadFailed === 'function'
|
|
374
|
+
&& await deps.markMetadataUploadFailed(apiClient, target, buildId, `delta upload failed: ${safeText(error.message)}`, deps.log);
|
|
375
|
+
return {
|
|
376
|
+
handled: true,
|
|
377
|
+
result: {
|
|
378
|
+
success: false,
|
|
379
|
+
delta: true,
|
|
380
|
+
error: safeText(error.message),
|
|
381
|
+
requestId: error.extra.requestId,
|
|
382
|
+
buildId,
|
|
383
|
+
markedFailed: Boolean(markedFailed),
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function runDelta(apiClient, target, bundle, deps, attach) {
|
|
390
|
+
const { postThroughService, formatBytes, log } = deps;
|
|
391
|
+
const project = encodeURIComponent(target.project || 'main');
|
|
392
|
+
const identity = apiIdentity(apiClient);
|
|
393
|
+
const { body, files, entries } = await readBundle(bundle, target, attach);
|
|
394
|
+
const serialized = JSON.stringify(body);
|
|
395
|
+
const idempotencyKey = sha256Hex(`${target.project || 'main'}\n${body.version}\n${sha256Hex(serialized)}`);
|
|
396
|
+
|
|
397
|
+
// 1. Manifest: which pictures does the project not hold yet?
|
|
398
|
+
log.info(`Delta upload: ${files.size} distinct pictures in ${entries} entries; asking which are new...`);
|
|
399
|
+
let answer;
|
|
400
|
+
try {
|
|
401
|
+
const response = await postThroughService(apiClient, `/delta/${project}/manifest`, serialized, {
|
|
402
|
+
label: 'Delta manifest',
|
|
403
|
+
bytes: Buffer.byteLength(serialized),
|
|
404
|
+
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
|
|
405
|
+
maxRedirects: 0, // F64: the client sends X-API-Key; a redirect here would carry it to another host
|
|
406
|
+
log,
|
|
407
|
+
});
|
|
408
|
+
answer = response.data;
|
|
409
|
+
} catch (error) {
|
|
410
|
+
throw stopForManifestError(error);
|
|
411
|
+
}
|
|
412
|
+
const objects = readObjects(answer);
|
|
413
|
+
// A service that does not know `buildId` opens a build of its own. Never carry on with a second build.
|
|
414
|
+
if (answer.buildId !== attach.buildId) throw fallback('the upload service opened a different build than the one named, so it cannot attach pictures');
|
|
415
|
+
|
|
416
|
+
// 2. Send only what the answer asks for.
|
|
417
|
+
const toSend = [];
|
|
418
|
+
for (const [oid, picture] of files) {
|
|
419
|
+
const object = objects.get(oid);
|
|
420
|
+
if (object && object.error) throw fallback(`the upload service refused a picture (${object.error})`);
|
|
421
|
+
if (object && object.upload) toSend.push({ oid, picture, action: object.upload });
|
|
422
|
+
}
|
|
423
|
+
for (const item of toSend) checkHref(item.action.href, identity.base);
|
|
424
|
+
const sentBytes = await sendPictures(toSend, identity, deps);
|
|
425
|
+
log.info(`pictures: sent ${toSend.length} of ${files.size} (${formatBytes(sentBytes)}), already held ${files.size - toSend.length}`);
|
|
426
|
+
|
|
427
|
+
// 3. Commit: the server checks every picture is held before it queues the build.
|
|
428
|
+
const commit = await commitBuild(apiClient, project, attach.buildId, deps);
|
|
429
|
+
const data = commit.data || {};
|
|
430
|
+
const buildNumber = data.buildNumber ?? answer.buildNumber;
|
|
431
|
+
log.success(`Delta upload committed (build #${buildNumber ?? 'n/a'}, queued: ${data.queued !== false})`);
|
|
432
|
+
return {
|
|
433
|
+
success: true,
|
|
434
|
+
delta: true,
|
|
435
|
+
status: commit.status,
|
|
436
|
+
// 202 queued; 200 = this build was already accepted by an earlier commit.
|
|
437
|
+
queued: commit.status === 200 ? true : Boolean(data.queued),
|
|
438
|
+
buildId: attach.buildId,
|
|
439
|
+
buildNumber,
|
|
440
|
+
picturesSent: toSend.length,
|
|
441
|
+
picturesTotal: files.size,
|
|
442
|
+
bytesSent: sentBytes,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** PUT the pictures, PUT_CONCURRENCY at a time; returns the bytes sent. Any failure is a fallback. */
|
|
447
|
+
async function sendPictures(toSend, identity, deps) {
|
|
448
|
+
const { uploadTimeoutMs, isTransientUploadError, log } = deps;
|
|
449
|
+
let sentBytes = 0;
|
|
450
|
+
try {
|
|
451
|
+
await runPool(toSend, PUT_CONCURRENCY, async (item) => {
|
|
452
|
+
// Not `sentBytes += await ...`: that reads sentBytes before the await and loses concurrent additions.
|
|
453
|
+
const bytes = await putPicture({ action: item.action, picture: item.picture, identity, uploadTimeoutMs, isTransient: isTransientUploadError, log });
|
|
454
|
+
sentBytes += bytes;
|
|
455
|
+
});
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (error instanceof DeltaStop) throw error;
|
|
458
|
+
// The build stays open on the server and expires by itself; the ZIP path makes a new one.
|
|
459
|
+
throw fallback(`a picture could not be sent (${describe(error)})`);
|
|
460
|
+
}
|
|
461
|
+
return sentBytes;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** How many pictures a missing_blobs answer says are missing: `count` is the full number, `missing` is capped by the service (1000). */
|
|
465
|
+
function missingCount(data) {
|
|
466
|
+
if (Number.isInteger(data?.count)) return String(data.count);
|
|
467
|
+
return Array.isArray(data?.missing) ? String(data.missing.length) : 'some';
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Why a commit that was refused did not queue the build: one line, the count when the service gave one. */
|
|
471
|
+
function commitFailureText(error) {
|
|
472
|
+
const status = error?.response?.status;
|
|
473
|
+
const code = codeOf(error);
|
|
474
|
+
const data = error?.response?.data;
|
|
475
|
+
if (status === 422 && code === 'invalid_picture') {
|
|
476
|
+
// The service looked at pictures sent straight to storage, removed the ones that are not valid pictures
|
|
477
|
+
// and did not queue the build. The ZIP would carry the same pictures, so this is a failure, not a fallback.
|
|
478
|
+
const count = Number(data?.count);
|
|
479
|
+
const some = Number.isInteger(count) && count > 0 ? String(count) : 'some';
|
|
480
|
+
return `the upload service says ${some} ${count === 1 ? 'picture is' : 'pictures are'} not a valid picture (invalid_picture), so the build was not queued`;
|
|
481
|
+
}
|
|
482
|
+
if (status === 409 && code === 'missing_blobs') {
|
|
483
|
+
return `the upload service says ${missingCount(data)} pictures never arrived (missing_blobs), so the build was not queued`;
|
|
484
|
+
}
|
|
485
|
+
return `the delta build could not be committed (${describe(error)}), so it was not queued`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** POST commit. A refusal or an unreachable server is a failure (never green, never a ZIP behind it). */
|
|
489
|
+
async function commitBuild(apiClient, project, buildId, deps) {
|
|
490
|
+
try {
|
|
491
|
+
return await deps.postThroughService(apiClient, `/delta/${project}/builds/${encodeURIComponent(buildId)}/commit`, '{}', {
|
|
492
|
+
label: 'Delta commit',
|
|
493
|
+
bytes: 0,
|
|
494
|
+
headers: { 'Content-Type': 'application/json' },
|
|
495
|
+
maxRedirects: 0, // F64: same as the manifest call
|
|
496
|
+
log: deps.log,
|
|
497
|
+
});
|
|
498
|
+
} catch (error) {
|
|
499
|
+
throw fail(commitFailureText(error), { requestId: requestIdOf(error) });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
module.exports = {
|
|
504
|
+
PROTOCOL_VERSION,
|
|
505
|
+
TRANSFERS,
|
|
506
|
+
PUT_CONCURRENCY,
|
|
507
|
+
PUT_RETRIES,
|
|
508
|
+
deltaEnabled,
|
|
509
|
+
locateDeltaBundle,
|
|
510
|
+
removeDeltaBundle,
|
|
511
|
+
uploadViaDelta,
|
|
512
|
+
// Tests only.
|
|
513
|
+
__runPool: runPool,
|
|
514
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scrymore/scry-deployer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "A CLI to automate the deployment of Storybook static builds.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@hyzyla/pdfium": "2.1.13",
|
|
40
40
|
"@octokit/rest": "^20.0.0",
|
|
41
|
-
"@scrymore/scry-sbcov": "^0.
|
|
41
|
+
"@scrymore/scry-sbcov": "^0.9.0",
|
|
42
42
|
"@sentry/node": "^10.33.0",
|
|
43
43
|
"archiver": "^7.0.1",
|
|
44
44
|
"axios": "^1.12.2",
|