@graphty/visual-review 0.2.1 → 0.2.2
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 +424 -148
- package/package.json +1 -1
- package/templates/visual-review.yml +4 -2
- package/trusted/gate.mjs +239 -41
- package/trusted/lib/accept.mjs +413 -94
- package/trusted/lib/approval.mjs +300 -0
- package/trusted/lib/github.mjs +96 -29
- package/trusted/lib/serve.mjs +850 -101
- package/trusted/lib/thumbs.mjs +149 -0
- package/trusted/page/index.html +20 -6
- package/trusted/page/passkey.js +94 -0
- package/trusted/page/review.css +915 -102
- package/trusted/page/review.js +3062 -1044
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkey approvals of review records: the owner's device (Face ID or Touch ID, a passkey in
|
|
3
|
+
* iCloud Keychain) signs the SHA-256 of a review record, and this module checks that signature.
|
|
4
|
+
* The gate runs it on every record a pull request adds; the server runs it at Finish, immediately
|
|
5
|
+
* before the record is committed.
|
|
6
|
+
*
|
|
7
|
+
* WebAuthn's ES256 signature is over `authenticatorData || SHA-256(clientDataJSON)`, and the
|
|
8
|
+
* challenge inside clientDataJSON is the record's hash, so the signature covers the record. Only
|
|
9
|
+
* `node:crypto`, so the gate still runs from a checkout without an install.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
/** Where the registered public keys live, relative to the repository root. */
|
|
15
|
+
export const PASSKEYS_FILE = "visual-review/passkeys.json";
|
|
16
|
+
|
|
17
|
+
const B64U = /^[A-Za-z0-9_-]+$/;
|
|
18
|
+
const HOST = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
|
|
19
|
+
const FIELDS = ["credentialId", "authenticatorData", "clientDataJSON", "signature"];
|
|
20
|
+
const UP = 0x01;
|
|
21
|
+
const UV = 0x04;
|
|
22
|
+
const AT = 0x40;
|
|
23
|
+
|
|
24
|
+
const sha256 = (bytes) => createHash("sha256").update(bytes).digest();
|
|
25
|
+
const b64u = (s) => Buffer.from(s, "base64url");
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* JSON with object keys sorted and no whitespace, so one record has exactly one byte form. A
|
|
29
|
+
* number JSON cannot write back as it was read (1e999 is Infinity, -0 is 0) throws, so two
|
|
30
|
+
* different records never hash the same.
|
|
31
|
+
* @param {unknown} value a JSON value
|
|
32
|
+
* @returns {string} its canonical text
|
|
33
|
+
*/
|
|
34
|
+
export function canonical(value) {
|
|
35
|
+
if (typeof value === "number" && (!Number.isFinite(value) || Object.is(value, -0))) {
|
|
36
|
+
throw new Error(`${value} has no canonical JSON form`);
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
return `[${value.map((v) => canonical(v ?? null)).join(",")}]`;
|
|
40
|
+
}
|
|
41
|
+
if (value !== null && typeof value === "object") {
|
|
42
|
+
const keys = Object.keys(value)
|
|
43
|
+
.filter((k) => value[k] !== undefined)
|
|
44
|
+
.sort();
|
|
45
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
|
|
46
|
+
}
|
|
47
|
+
return JSON.stringify(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The bytes an approval signs: SHA-256 of the canonical record without its `approval`.
|
|
52
|
+
* @param {object} record the review record
|
|
53
|
+
* @returns {Buffer} 32 bytes
|
|
54
|
+
*/
|
|
55
|
+
export function recordHash(record) {
|
|
56
|
+
const rest = { ...record };
|
|
57
|
+
delete rest.approval;
|
|
58
|
+
return sha256(Buffer.from(canonical(rest), "utf8"));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Loads a key's SubjectPublicKeyInfo; only P-256 (ES256) is accepted.
|
|
63
|
+
* @param {string} spki base64url DER
|
|
64
|
+
* @returns {import("node:crypto").KeyObject} the key
|
|
65
|
+
*/
|
|
66
|
+
function loadKey(spki) {
|
|
67
|
+
const key = createPublicKey({ key: b64u(spki), format: "der", type: "spki" });
|
|
68
|
+
if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") {
|
|
69
|
+
throw new Error("the public key is not a P-256 (ES256) key");
|
|
70
|
+
}
|
|
71
|
+
return key;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parses passkeys.json. One bad entry makes the whole file invalid, so the gate fails closed.
|
|
76
|
+
* @param {string} text the file
|
|
77
|
+
* @returns {{ id: string, publicKey: string, rpId: string, label?: string, registeredAt?: string }[]}
|
|
78
|
+
* the keys
|
|
79
|
+
*/
|
|
80
|
+
export function parsePasskeys(text) {
|
|
81
|
+
const doc = JSON.parse(text);
|
|
82
|
+
if (doc?.version !== 1) {
|
|
83
|
+
throw new Error("version must be 1");
|
|
84
|
+
}
|
|
85
|
+
if (!Array.isArray(doc.keys)) {
|
|
86
|
+
throw new Error("keys must be an array");
|
|
87
|
+
}
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
for (const [i, k] of doc.keys.entries()) {
|
|
90
|
+
const where = `keys[${i}]`;
|
|
91
|
+
if (typeof k?.id !== "string" || !B64U.test(k.id)) {
|
|
92
|
+
throw new Error(`${where}.id must be a non-empty base64url string`);
|
|
93
|
+
}
|
|
94
|
+
if (seen.has(k.id)) {
|
|
95
|
+
throw new Error(`${where}.id is a duplicate`);
|
|
96
|
+
}
|
|
97
|
+
seen.add(k.id);
|
|
98
|
+
if (typeof k.rpId !== "string" || !HOST.test(k.rpId)) {
|
|
99
|
+
throw new Error(`${where}.rpId must be a host name`);
|
|
100
|
+
}
|
|
101
|
+
if (typeof k.publicKey !== "string" || !B64U.test(k.publicKey)) {
|
|
102
|
+
throw new Error(`${where}.publicKey must be base64url`);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
loadKey(k.publicKey);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw new Error(`${where}.publicKey: ${err.message}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return doc.keys;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Checks clientDataJSON and authenticatorData shared by registration and approval.
|
|
115
|
+
* @param {object} c the ceremony
|
|
116
|
+
* @param {any} c.clientData the parsed clientDataJSON
|
|
117
|
+
* @param {Buffer} c.authData authenticatorData
|
|
118
|
+
* @param {string} c.type webauthn.get or webauthn.create
|
|
119
|
+
* @param {string} c.challenge the expected challenge, base64url
|
|
120
|
+
* @param {string} c.rpId the relying party id
|
|
121
|
+
* @param {(origin: unknown) => boolean} c.originOk whether the origin is acceptable
|
|
122
|
+
* @returns {string | null} why not, or null
|
|
123
|
+
*/
|
|
124
|
+
function checkCeremony({ clientData, authData, type, challenge, rpId, originOk }) {
|
|
125
|
+
if (clientData?.type !== type) {
|
|
126
|
+
return `clientDataJSON.type is not ${type}`;
|
|
127
|
+
}
|
|
128
|
+
if (clientData.challenge !== challenge) {
|
|
129
|
+
return "the challenge does not match: it was signed for other contents than these";
|
|
130
|
+
}
|
|
131
|
+
if (clientData.crossOrigin === true) {
|
|
132
|
+
return "the approval was made in a cross-origin frame";
|
|
133
|
+
}
|
|
134
|
+
if (!originOk(clientData.origin)) {
|
|
135
|
+
return `the approval was made on ${JSON.stringify(clientData.origin)}, not on the review page's origin`;
|
|
136
|
+
}
|
|
137
|
+
if (authData.length < 37) {
|
|
138
|
+
return "authenticatorData is too short";
|
|
139
|
+
}
|
|
140
|
+
if (!authData.subarray(0, 32).equals(sha256(rpId))) {
|
|
141
|
+
return `authenticatorData is not for the rpId ${rpId}`;
|
|
142
|
+
}
|
|
143
|
+
if (!(authData[32] & UP)) {
|
|
144
|
+
return "the authenticator did not confirm user presence";
|
|
145
|
+
}
|
|
146
|
+
if (!(authData[32] & UV)) {
|
|
147
|
+
return "the authenticator did not verify the user (no Face ID, Touch ID or PIN)";
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const jsonOf = (s) => {
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(b64u(s).toString("utf8"));
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether a record's approval is a valid passkey signature over that record. Never throws.
|
|
162
|
+
* @param {object} record the review record, with `approval`
|
|
163
|
+
* @param {{ id: string, publicKey: string, rpId: string }[]} keys the keys to accept
|
|
164
|
+
* @param {{ origin?: string }} [options] the exact origin required (the server's); without it any
|
|
165
|
+
* https origin whose host is exactly the key's rpId, on any port (servherd assigns the review
|
|
166
|
+
* server's), never a subdomain
|
|
167
|
+
* @returns {string | null} why it fails, or null when it verifies
|
|
168
|
+
*/
|
|
169
|
+
export function verifyApproval(record, keys, { origin } = {}) {
|
|
170
|
+
const a = record?.approval;
|
|
171
|
+
if (typeof a !== "object" || a === null) {
|
|
172
|
+
return "the record has no passkey approval";
|
|
173
|
+
}
|
|
174
|
+
for (const f of FIELDS) {
|
|
175
|
+
if (typeof a[f] !== "string" || !B64U.test(a[f])) {
|
|
176
|
+
return `approval.${f} is not base64url`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const key = keys.find((k) => k.id === a.credentialId);
|
|
180
|
+
if (!key) {
|
|
181
|
+
return "approval is from a key not in passkeys.json on the base branch";
|
|
182
|
+
}
|
|
183
|
+
const clientDataBytes = b64u(a.clientDataJSON);
|
|
184
|
+
const clientData = jsonOf(a.clientDataJSON);
|
|
185
|
+
if (clientData === undefined) {
|
|
186
|
+
return "approval.clientDataJSON is not JSON";
|
|
187
|
+
}
|
|
188
|
+
const authData = b64u(a.authenticatorData);
|
|
189
|
+
let challenge;
|
|
190
|
+
try {
|
|
191
|
+
challenge = recordHash(record).toString("base64url");
|
|
192
|
+
} catch (err) {
|
|
193
|
+
return `the record cannot be hashed: ${err.message}`;
|
|
194
|
+
}
|
|
195
|
+
const why = checkCeremony({
|
|
196
|
+
clientData,
|
|
197
|
+
authData,
|
|
198
|
+
type: "webauthn.get",
|
|
199
|
+
challenge,
|
|
200
|
+
rpId: key.rpId,
|
|
201
|
+
originOk: (o) => {
|
|
202
|
+
if (origin !== undefined) {
|
|
203
|
+
return o === origin;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
// u.origin === o refuses anything a browser never writes as an origin (a user, a path).
|
|
207
|
+
const u = new URL(String(o));
|
|
208
|
+
return u.origin === o && u.protocol === "https:" && u.hostname === key.rpId;
|
|
209
|
+
} catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
if (why) {
|
|
215
|
+
return why;
|
|
216
|
+
}
|
|
217
|
+
let ok = false;
|
|
218
|
+
try {
|
|
219
|
+
ok = verify(
|
|
220
|
+
"sha256",
|
|
221
|
+
Buffer.concat([authData, sha256(clientDataBytes)]),
|
|
222
|
+
loadKey(key.publicKey),
|
|
223
|
+
b64u(a.signature),
|
|
224
|
+
);
|
|
225
|
+
} catch {
|
|
226
|
+
ok = false;
|
|
227
|
+
}
|
|
228
|
+
return ok ? null : "the approval's signature does not verify";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The gate's check of one record a pull request adds, once approvals are enforced.
|
|
233
|
+
* @param {object} record the parsed record
|
|
234
|
+
* @param {{ id: string, publicKey: string, rpId: string }[]} keys the base branch's keys
|
|
235
|
+
* @param {{ pr: number }} options the pull request the gate runs on
|
|
236
|
+
* @returns {string | null} why it fails, or null
|
|
237
|
+
*/
|
|
238
|
+
export function verifyRecord(record, keys, { pr }) {
|
|
239
|
+
if (typeof record !== "object" || record === null || Array.isArray(record)) {
|
|
240
|
+
return "not a review record";
|
|
241
|
+
}
|
|
242
|
+
if (record.version !== 2) {
|
|
243
|
+
return `a version ${JSON.stringify(record.version)} record has no passkey approval; review it again with Face ID`;
|
|
244
|
+
}
|
|
245
|
+
if (!Array.isArray(record.items) || !Array.isArray(record.rejects)) {
|
|
246
|
+
return "items and rejects must be arrays";
|
|
247
|
+
}
|
|
248
|
+
if (record.pr !== pr && record.pr !== null) {
|
|
249
|
+
return `the record is for pull request #${record.pr}, not #${pr}`;
|
|
250
|
+
}
|
|
251
|
+
return verifyApproval(record, keys);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The server's check of a `navigator.credentials.create` response. Attestation is not checked
|
|
256
|
+
* (Apple passkeys give "none"): the owner merging the key's pull request is the trust step.
|
|
257
|
+
* @param {{ credentialId: string, publicKey: string, algorithm: number, authenticatorData: string,
|
|
258
|
+
* clientDataJSON: string }} input what the page sent
|
|
259
|
+
* @param {{ challenge: string, origin: string, rpId: string }} expected the server's challenge
|
|
260
|
+
* (base64url), its origin and rpId
|
|
261
|
+
* @returns {string | null} why it fails, or null
|
|
262
|
+
*/
|
|
263
|
+
export function verifyRegistration(input, { challenge, origin, rpId }) {
|
|
264
|
+
for (const f of ["credentialId", "publicKey", "authenticatorData", "clientDataJSON"]) {
|
|
265
|
+
if (typeof input?.[f] !== "string" || !B64U.test(input[f])) {
|
|
266
|
+
return `${f} is not base64url`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (input.algorithm !== -7) {
|
|
270
|
+
return `algorithm ${input.algorithm} is not ES256 (-7)`;
|
|
271
|
+
}
|
|
272
|
+
const clientData = jsonOf(input.clientDataJSON);
|
|
273
|
+
if (clientData === undefined) {
|
|
274
|
+
return "clientDataJSON is not JSON";
|
|
275
|
+
}
|
|
276
|
+
const authData = b64u(input.authenticatorData);
|
|
277
|
+
const why = checkCeremony({
|
|
278
|
+
clientData,
|
|
279
|
+
authData,
|
|
280
|
+
type: "webauthn.create",
|
|
281
|
+
challenge,
|
|
282
|
+
rpId,
|
|
283
|
+
originOk: (o) => o === origin,
|
|
284
|
+
});
|
|
285
|
+
if (why) {
|
|
286
|
+
return why;
|
|
287
|
+
}
|
|
288
|
+
// The attested credential data names the credential: it must be the id sent.
|
|
289
|
+
const id = b64u(input.credentialId);
|
|
290
|
+
const length = authData.length >= 55 ? authData.readUInt16BE(53) : -1;
|
|
291
|
+
if (!(authData[32] & AT) || length !== id.length || !authData.subarray(55, 55 + length).equals(id)) {
|
|
292
|
+
return "authenticatorData does not hold this credential id";
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
loadKey(input.publicKey);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
return err.message;
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
package/trusted/lib/github.mjs
CHANGED
|
@@ -57,6 +57,14 @@ export const ghRunner = (cwd) => withRetries((args, input) => exec("gh", args, {
|
|
|
57
57
|
const TRANSIENT =
|
|
58
58
|
/could not resolve host|no such host|error connecting to|connection (reset|refused|timed out)|i\/o timeout|TLS handshake timeout|HTTP 5\d\d|unexpected EOF|GOAWAY|stream error|context deadline exceeded|timed out after/i;
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The gh calls waiting to retry after a network failure, newest last, each with its error, which
|
|
62
|
+
* try it waits for and until when: the review page shows the newest, so a dropped DNS lookup reads
|
|
63
|
+
* as "retrying" and never as a hang.
|
|
64
|
+
* @type {Set<{ error: string, attempt: number, of: number, until: number }>}
|
|
65
|
+
*/
|
|
66
|
+
export const retrying = new Set();
|
|
67
|
+
|
|
60
68
|
/**
|
|
61
69
|
* Retries a gh runner's calls that failed on the network, after each delay in turn. A write
|
|
62
70
|
* (`--input`) is never retried: GitHub may have applied it before the connection dropped. Every
|
|
@@ -79,7 +87,15 @@ export const withRetries =
|
|
|
79
87
|
if (!retry) {
|
|
80
88
|
throw err;
|
|
81
89
|
}
|
|
90
|
+
const wait = {
|
|
91
|
+
error: err.message.split("\n")[0],
|
|
92
|
+
attempt: i + 2,
|
|
93
|
+
of: delays.length + 1,
|
|
94
|
+
until: Date.now() + delays[i],
|
|
95
|
+
};
|
|
96
|
+
retrying.add(wait);
|
|
82
97
|
await new Promise((resolve) => setTimeout(resolve, delays[i]));
|
|
98
|
+
retrying.delete(wait);
|
|
83
99
|
}
|
|
84
100
|
}
|
|
85
101
|
};
|
|
@@ -158,6 +174,29 @@ export async function visualJobs(gh, run, attempt, projects) {
|
|
|
158
174
|
// Downloads in flight, by target directory: concurrent refreshes of one run await the same one.
|
|
159
175
|
const downloading = new Map();
|
|
160
176
|
|
|
177
|
+
// At most this many `gh run download` at once: every open pull request's captures start at once,
|
|
178
|
+
// and dozens of parallel transfers only slow each other (and the one the reviewer is waiting for).
|
|
179
|
+
const DOWNLOADS = 8;
|
|
180
|
+
let active = 0;
|
|
181
|
+
/** Downloads waiting for a slot, by target directory, in the order they start. */
|
|
182
|
+
const queued = [];
|
|
183
|
+
const pump = () => {
|
|
184
|
+
while (active < DOWNLOADS && queued.length > 0) {
|
|
185
|
+
active++;
|
|
186
|
+
queued.shift().start();
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Moves the waiting downloads whose directory `wanted` picks to the front of the queue: the
|
|
192
|
+
* project the reviewer is opening downloads next, before the ones nobody is waiting for.
|
|
193
|
+
* @param {(dir: string) => boolean} wanted picks the directories to hurry
|
|
194
|
+
*/
|
|
195
|
+
export function hurry(wanted) {
|
|
196
|
+
const first = queued.filter((q) => wanted(q.dir));
|
|
197
|
+
queued.splice(0, queued.length, ...first, ...queued.filter((q) => !wanted(q.dir)));
|
|
198
|
+
}
|
|
199
|
+
|
|
161
200
|
/**
|
|
162
201
|
* Downloads one artifact into `dir`, unless it is already there. It is extracted into a sibling
|
|
163
202
|
* temporary directory and renamed into place only once its results.json is there, so `dir` either
|
|
@@ -192,6 +231,10 @@ function download(gh, runId, name, dir) {
|
|
|
192
231
|
rmSync(join(dirname(dir), f), { recursive: true, force: true });
|
|
193
232
|
}
|
|
194
233
|
}
|
|
234
|
+
await new Promise((start) => {
|
|
235
|
+
queued.push({ dir, start });
|
|
236
|
+
pump();
|
|
237
|
+
});
|
|
195
238
|
const part = mkdtempSync(`${dir}.part-`);
|
|
196
239
|
try {
|
|
197
240
|
await gh(["run", "download", String(runId), "-n", name, "-D", part]);
|
|
@@ -200,6 +243,8 @@ function download(gh, runId, name, dir) {
|
|
|
200
243
|
}
|
|
201
244
|
} finally {
|
|
202
245
|
rmSync(part, { recursive: true, force: true });
|
|
246
|
+
active--;
|
|
247
|
+
pump();
|
|
203
248
|
}
|
|
204
249
|
})().finally(() => downloading.delete(dir));
|
|
205
250
|
downloading.set(dir, done);
|
|
@@ -218,11 +263,16 @@ function download(gh, runId, name, dir) {
|
|
|
218
263
|
* @param {string[]} projects project ids
|
|
219
264
|
* @param {string} tmp the download root
|
|
220
265
|
* @param {string[]} [others] receives the projects the run captured that are not in `projects`
|
|
221
|
-
* @
|
|
222
|
-
*
|
|
223
|
-
*
|
|
266
|
+
* @param {(project: string, got: object | null) => void} [landed] told as each project's download
|
|
267
|
+
* ends, with what the result holds for it, so a page can fill rows in one by one
|
|
268
|
+
* @param {(sizes: Record<string, number>) => void} [planned] told first, with the size in bytes
|
|
269
|
+
* of each project's artifact, so a page can count the bytes still to come
|
|
270
|
+
* @returns {Promise<Record<string, { dir: string | null, attempt: number, bytes: number,
|
|
271
|
+
* error?: string, expired?: true } | null>>} null for a project with no artifact; `error`
|
|
272
|
+
* (and no `dir`) when its download failed; `expired` (and no `dir`) when GitHub deleted it and
|
|
273
|
+
* it is not on disk
|
|
224
274
|
*/
|
|
225
|
-
export async function downloadCaptures(gh, run, projects, tmp, others = []) {
|
|
275
|
+
export async function downloadCaptures(gh, run, projects, tmp, others = [], landed = () => {}, planned = () => {}) {
|
|
226
276
|
const { artifacts } = await api(gh, `repos/{owner}/{repo}/actions/runs/${run.id}/artifacts?per_page=100`);
|
|
227
277
|
for (const a of artifacts) {
|
|
228
278
|
const p = /^visual-(.+)-\d+$/.exec(a.name)?.[1];
|
|
@@ -230,32 +280,49 @@ export async function downloadCaptures(gh, run, projects, tmp, others = []) {
|
|
|
230
280
|
others.push(p);
|
|
231
281
|
}
|
|
232
282
|
}
|
|
233
|
-
|
|
283
|
+
const newest = Object.fromEntries(
|
|
284
|
+
projects.map((project) => {
|
|
285
|
+
const pattern = new RegExp(`^visual-${project}-(\\d+)$`);
|
|
286
|
+
const a = artifacts
|
|
287
|
+
.filter((x) => pattern.test(x.name))
|
|
288
|
+
.map((x) => ({
|
|
289
|
+
name: x.name,
|
|
290
|
+
expired: x.expired,
|
|
291
|
+
bytes: x.size_in_bytes ?? 0,
|
|
292
|
+
attempt: Number(pattern.exec(x.name)[1]),
|
|
293
|
+
}))
|
|
294
|
+
.sort((x, y) => y.attempt - x.attempt)[0];
|
|
295
|
+
return [project, a ?? null];
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
planned(Object.fromEntries(Object.entries(newest).flatMap(([p, a]) => (a ? [[p, a.bytes]] : []))));
|
|
299
|
+
/** @type {Record<string, { dir: string | null, attempt: number, bytes: number, error?: string, expired?: true } | null>} */
|
|
234
300
|
const out = {};
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
301
|
+
// Every project at once: the shared cap on downloads keeps the number of transfers sane.
|
|
302
|
+
await Promise.all(
|
|
303
|
+
projects.map(async (project) => {
|
|
304
|
+
const a = newest[project];
|
|
305
|
+
if (!a) {
|
|
306
|
+
out[project] = null;
|
|
307
|
+
} else {
|
|
308
|
+
const dir = join(tmp, `${run.id}-${a.attempt}`, project);
|
|
309
|
+
const got = { attempt: a.attempt, bytes: a.bytes };
|
|
310
|
+
if (a.expired) {
|
|
311
|
+
out[project] = existsSync(join(dir, "results.json"))
|
|
312
|
+
? { dir, ...got }
|
|
313
|
+
: { dir: null, ...got, expired: true };
|
|
314
|
+
} else {
|
|
315
|
+
try {
|
|
316
|
+
await download(gh, run.id, a.name, dir);
|
|
317
|
+
out[project] = { dir, ...got };
|
|
318
|
+
} catch (err) {
|
|
319
|
+
out[project] = { dir: null, ...got, error: err.message };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
landed(project, out[project]);
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
259
326
|
return out;
|
|
260
327
|
}
|
|
261
328
|
|