@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.
- package/README.md +448 -144
- package/capture/capture.mjs +7 -0
- package/package.json +1 -1
- package/templates/visual-review.yml +4 -2
- package/trusted/cli.mjs +1 -0
- package/trusted/gate.mjs +246 -42
- package/trusted/lib/accept.mjs +474 -116
- package/trusted/lib/approval.mjs +300 -0
- package/trusted/lib/config.mjs +4 -3
- package/trusted/lib/github.mjs +176 -29
- package/trusted/lib/serve.mjs +1237 -154
- 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 +924 -92
- package/trusted/page/review.js +3238 -991
|
@@ -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/config.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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`);
|
package/trusted/lib/github.mjs
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
164
|
-
*
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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;
|