@graphty/visual-review 0.2.0 → 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.
@@ -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
+ }
@@ -21,8 +21,9 @@ const DEFAULTS = {
21
21
  issueLabels: ["bug"],
22
22
  };
23
23
 
24
- // Project ids name artifacts, jobs, directories and regular expressions, so they stay plain.
25
- const PROJECT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
24
+ // Project ids name artifacts, jobs, directories and regular expressions, so they stay plain; and
25
+ // results.json allows only these.
26
+ const PROJECT_ID = /^[a-z0-9][a-z0-9-]*$/;
26
27
  // A path inside the repository: relative, no "..", no backslashes.
27
28
  const REPO_PATH = /^(?!\/)(?!.*(^|\/)\.\.(\/|$))[^\\]+$/;
28
29
 
@@ -72,7 +73,7 @@ export function normalizeConfig(input) {
72
73
  for (const [id, p] of Object.entries(projects)) {
73
74
  const where = `projects.${id}`;
74
75
  if (!PROJECT_ID.test(id)) {
75
- fail(`${where}: a project id is letters, digits, ".", "_" and "-"`);
76
+ fail(`${where}: a project id is lowercase letters, digits and "-"`);
76
77
  }
77
78
  if (typeof p !== "object" || p === null) {
78
79
  fail(`${where} must be an object`);
@@ -10,22 +10,29 @@
10
10
  */
11
11
 
12
12
  import { execFile } from "node:child_process";
13
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from "node:fs";
14
- import { dirname, join } from "node:path";
13
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
14
+ import { basename, dirname, join } from "node:path";
15
15
 
16
16
  import { validateResults } from "./results.mjs";
17
17
 
18
18
  /**
19
- * Runs a program and resolves with its trimmed stdout.
19
+ * Runs a program and resolves with its trimmed stdout. A program that runs longer than
20
+ * VISUAL_REVIEW_TIMEOUT_MS (10 minutes by default) is killed: a gh or git call that stalls on a
21
+ * dead network, or waits on a prompt nobody sees, would otherwise hold the page or Finish forever.
20
22
  * @param {string} cmd the program
21
23
  * @param {string[]} args its arguments
22
24
  * @param {{ cwd?: string, input?: string, env?: object }} [options] stdin and environment
23
25
  * @returns {Promise<string>} stdout; rejects with an Error whose message is the program's stderr
24
26
  */
25
27
  export function exec(cmd, args, { cwd, input, env } = {}) {
28
+ const timeout = Number(process.env.VISUAL_REVIEW_TIMEOUT_MS) || 600000;
26
29
  return new Promise((resolve, reject) => {
27
- const child = execFile(cmd, args, { cwd, env, encoding: "utf8", maxBuffer: 64 << 20 }, (err, out, stderr) => {
28
- if (err) {
30
+ // SIGTERM (the default) lets git remove its lock files as it exits.
31
+ const options = { cwd, env, encoding: /** @type {const} */ ("utf8"), maxBuffer: 64 << 20, timeout };
32
+ const child = execFile(cmd, args, options, (err, out, stderr) => {
33
+ if (err?.killed) {
34
+ reject(new Error(`${cmd} ${args.join(" ")} timed out after ${timeout / 1000} s and was stopped`));
35
+ } else if (err) {
29
36
  reject(new Error(stderr.trim() || err.message));
30
37
  } else {
31
38
  resolve(out.trim());
@@ -42,7 +49,56 @@ export function exec(cmd, args, { cwd, input, env } = {}) {
42
49
  * @param {string} cwd the repository
43
50
  * @returns {(args: string[], input?: string) => Promise<string>} runs gh and returns its stdout
44
51
  */
45
- export const ghRunner = (cwd) => (args, input) => exec("gh", args, { cwd, input });
52
+ export const ghRunner = (cwd) => withRetries((args, input) => exec("gh", args, { cwd, input }));
53
+
54
+ // How gh reports a network that failed (DNS, a dropped or refused connection, a transfer cut
55
+ // short, a call exec stopped) or a GitHub server error. A 4xx, a missing artifact or any other gh
56
+ // error is real and is never retried.
57
+ const TRANSIENT =
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
+
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
+
68
+ /**
69
+ * Retries a gh runner's calls that failed on the network, after each delay in turn. A write
70
+ * (`--input`) is never retried: GitHub may have applied it before the connection dropped. Every
71
+ * failure and retry is logged to stderr, so the server's log shows what happened.
72
+ * @param {(args: string[], input?: string) => Promise<string>} gh the gh runner
73
+ * @param {number[]} [delays] milliseconds before each retry
74
+ * @returns {(args: string[], input?: string) => Promise<string>} the retrying runner
75
+ */
76
+ export const withRetries =
77
+ (gh, delays = [2000, 5000, 15000]) =>
78
+ async (args, input) => {
79
+ for (let i = 0; ; i++) {
80
+ try {
81
+ return await gh(args, input);
82
+ } catch (err) {
83
+ const retry = i < delays.length && !args.includes("--input") && TRANSIENT.test(err.message);
84
+ // gh's arguments never hold a token (gh keeps its own login), so they are logged whole.
85
+ const next = retry ? `; retrying in ${delays[i] / 1000} s` : "";
86
+ console.error(`visual-review: gh ${args.join(" ")} failed${next}: ${err.message}`);
87
+ if (!retry) {
88
+ throw err;
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);
97
+ await new Promise((resolve) => setTimeout(resolve, delays[i]));
98
+ retrying.delete(wait);
99
+ }
100
+ }
101
+ };
46
102
 
47
103
  const api = async (gh, path) => JSON.parse(await gh(["api", path]));
48
104
 
@@ -108,7 +164,8 @@ export async function visualJobs(gh, run, attempt, projects) {
108
164
  const visual = jobs.filter((j) => /^visual\b/.test(j.name));
109
165
  return Object.fromEntries(
110
166
  projects.map((p) => {
111
- const job = visual.find((j) => j.name.includes(p));
167
+ // The exact name: "visual (graphty-element)" also contains "graphty".
168
+ const job = visual.find((j) => j.name === `visual (${p})`);
112
169
  return [p, job && { conclusion: job.conclusion, url: job.html_url }];
113
170
  }),
114
171
  );
@@ -117,6 +174,29 @@ export async function visualJobs(gh, run, attempt, projects) {
117
174
  // Downloads in flight, by target directory: concurrent refreshes of one run await the same one.
118
175
  const downloading = new Map();
119
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
+
120
200
  /**
121
201
  * Downloads one artifact into `dir`, unless it is already there. It is extracted into a sibling
122
202
  * temporary directory and renamed into place only once its results.json is there, so `dir` either
@@ -129,14 +209,32 @@ const downloading = new Map();
129
209
  * @returns {Promise<void>} settles when `dir` is complete, or the artifact had no results.json
130
210
  */
131
211
  function download(gh, runId, name, dir) {
132
- if (existsSync(join(dir, "results.json"))) {
133
- return Promise.resolve();
212
+ if (!downloading.has(dir) && existsSync(join(dir, "results.json"))) {
213
+ try {
214
+ JSON.parse(readFileSync(join(dir, "results.json"), "utf8"));
215
+ return Promise.resolve();
216
+ } catch (err) {
217
+ // Damaged on this disk (CI uploads only results.json it wrote): download it again.
218
+ console.error(
219
+ `visual-review: ${join(dir, "results.json")} is unreadable, downloading again: ${err.message}`,
220
+ );
221
+ }
134
222
  }
135
223
  if (!downloading.has(dir)) {
136
224
  const done = (async () => {
137
- // A directory without results.json is left over from before downloads were atomic.
225
+ // A directory without results.json is left over from before downloads were atomic,
226
+ // and a .part- sibling from a download a killed server never finished.
138
227
  rmSync(dir, { recursive: true, force: true });
139
228
  mkdirSync(dirname(dir), { recursive: true });
229
+ for (const f of readdirSync(dirname(dir))) {
230
+ if (f.startsWith(`${basename(dir)}.part-`)) {
231
+ rmSync(join(dirname(dir), f), { recursive: true, force: true });
232
+ }
233
+ }
234
+ await new Promise((start) => {
235
+ queued.push({ dir, start });
236
+ pump();
237
+ });
140
238
  const part = mkdtempSync(`${dir}.part-`);
141
239
  try {
142
240
  await gh(["run", "download", String(runId), "-n", name, "-D", part]);
@@ -145,6 +243,8 @@ function download(gh, runId, name, dir) {
145
243
  }
146
244
  } finally {
147
245
  rmSync(part, { recursive: true, force: true });
246
+ active--;
247
+ pump();
148
248
  }
149
249
  })().finally(() => downloading.delete(dir));
150
250
  downloading.set(dir, done);
@@ -155,32 +255,74 @@ function download(gh, runId, name, dir) {
155
255
  /**
156
256
  * Downloads each project's capture artifact from the highest attempt that uploaded one, into
157
257
  * `<tmp>/<run>-<attempt>/<project>/`. An artifact already downloaded is not fetched again, and
158
- * concurrent calls for the same one share a single download.
258
+ * concurrent calls for the same one share a single download. A failed download fails only its
259
+ * project, which the next call tries again. An expired artifact is used from disk when it was
260
+ * downloaded before.
159
261
  * @param {Function} gh the gh runner
160
262
  * @param {{ id: number }} run the run
161
263
  * @param {string[]} projects project ids
162
264
  * @param {string} tmp the download root
163
- * @returns {Promise<Record<string, { dir: string, attempt: number } | null>>} null for a project
164
- * with no unexpired artifact
265
+ * @param {string[]} [others] receives the projects the run captured that are not in `projects`
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
165
274
  */
166
- export async function downloadCaptures(gh, run, projects, tmp) {
275
+ export async function downloadCaptures(gh, run, projects, tmp, others = [], landed = () => {}, planned = () => {}) {
167
276
  const { artifacts } = await api(gh, `repos/{owner}/{repo}/actions/runs/${run.id}/artifacts?per_page=100`);
168
- /** @type {Record<string, { dir: string, attempt: number } | null>} */
169
- const out = {};
170
- for (const project of projects) {
171
- const pattern = new RegExp(`^visual-${project}-(\\d+)$`);
172
- const newest = artifacts
173
- .filter((a) => !a.expired && pattern.test(a.name))
174
- .map((a) => ({ name: a.name, attempt: Number(pattern.exec(a.name)[1]) }))
175
- .sort((a, b) => b.attempt - a.attempt)[0];
176
- if (!newest) {
177
- out[project] = null;
178
- continue;
277
+ for (const a of artifacts) {
278
+ const p = /^visual-(.+)-\d+$/.exec(a.name)?.[1];
279
+ if (p && !projects.includes(p) && !others.includes(p)) {
280
+ others.push(p);
179
281
  }
180
- const dir = join(tmp, `${run.id}-${newest.attempt}`, project);
181
- await download(gh, run.id, newest.name, dir);
182
- out[project] = { dir, attempt: newest.attempt };
183
282
  }
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>} */
300
+ const out = {};
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
+ );
184
326
  return out;
185
327
  }
186
328
 
@@ -205,7 +347,12 @@ export async function newestMasterCapture(gh, project, tmp, { workflow, defaultB
205
347
  if (!run) {
206
348
  continue;
207
349
  }
208
- const dir = (await downloadCaptures(gh, run, [project], tmp))[project]?.dir;
350
+ const got = (await downloadCaptures(gh, run, [project], tmp))[project];
351
+ if (got?.error) {
352
+ // Not skipped: an older capture would be compared instead of this one.
353
+ throw new Error(got.error);
354
+ }
355
+ const dir = got?.dir;
209
356
  let results = null;
210
357
  try {
211
358
  results = dir ? JSON.parse(readFileSync(join(dir, "results.json"), "utf8")) : null;