@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphty/visual-review",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Self-hosted visual review of Storybook stories: capture in GitHub Actions, keep the baselines in git (Git LFS), accept or reject each change in a local page, and gate pull requests",
5
5
  "author": "Adam Powers <apowers@ato.ms>",
6
6
  "type": "module",
@@ -117,7 +117,9 @@ jobs:
117
117
 
118
118
  # Fails while a project of the base branch's or the pull request's config, or with baselines on
119
119
  # the base branch (seeded or not), holds anything but unchanged or excluded items in its newest
120
- # capture, or a baseline file changed without a review record.
120
+ # capture, or a baseline file changed without a review record. Once visual-review/passkeys.json
121
+ # on the base branch holds a key, every review record the pull request adds must carry a passkey
122
+ # approval for this pull request (--pr) by one of the base branch's keys.
121
123
  # It runs the published gate at a pinned version, so a pull request's own dependencies cannot
122
124
  # change it. Depth 2: HEAD^1, the merge commit's first parent, is the base branch tip.
123
125
  visual-gate:
@@ -139,4 +141,4 @@ jobs:
139
141
  path: ${{ runner.temp }}/visual
140
142
 
141
143
  - name: Check visual changes were accepted
142
- run: npx --yes @graphty/visual-review@__VERSION__ gate --captures "$RUNNER_TEMP/visual" --base HEAD^1 --head HEAD
144
+ run: npx --yes @graphty/visual-review@__VERSION__ gate --captures "$RUNNER_TEMP/visual" --pr "${{ github.event.pull_request.number }}" --base HEAD^1 --head HEAD
package/trusted/gate.mjs CHANGED
@@ -14,21 +14,35 @@
14
14
  * leaves the visual jobs' old attempt as the newest) can neither hide nor resurrect a capture.
15
15
  * Which projects exist and are seeded is read from <ref> (the base branch tip, fetched by the
16
16
  * caller), and so is visual-review.config.json (the projects and where the baselines live), so
17
- * neither deleting a project's baselines, nor removing it from the config, nor moving the baselines
18
- * directory in the pull request turns the gate off. The pull request's config can only add projects. A gated project with no results.json, or an incomplete one, fails: a capture that
19
- * crashed has shown the owner nothing. An invalid results.json counts as missing.
17
+ * neither deleting a project's baselines nor removing it from the config turns the gate off, and a
18
+ * pull request that moves the baselines directory fails. The pull request's config can only add
19
+ * projects. A gated project with no results.json, or an incomplete one, fails: a capture that
20
+ * crashed has shown the owner nothing. An invalid results.json counts as missing. So does a story
21
+ * compared at a diffThreshold above MAX_THRESHOLD, which would hide real changes.
20
22
  *
21
- * It also fails when a baseline PNG, or a settings file that excludes a story, differs from the
22
- * base without a review record added in the pull request (<baselines>/reviews/*.json) naming
23
- * that path and its new hash. Without that, committing the captured PNGs straight into
24
- * the baselines directory would turn the capture check green with no review at all. Only a
25
- * record's items[].path and items[].to are read, so this proves a record names the change, not
26
- * that Finish wrote it or the owner pressed it (the README, "What the gate does and does not
27
- * guarantee").
23
+ * It also fails when a baseline PNG or a story's settings file differs from the base without the
24
+ * review records added in the pull request (<baselines>/reviews/*.json) taking that path from its
25
+ * contents on the base branch to its new ones: each record item moves a path `from` one hash `to`
26
+ * another, replayed in `reviewedAt` order, so a record approved for other contents (an old seed,
27
+ * a decision a later one replaced) moves nothing. Without that, committing the captured PNGs
28
+ * straight into the baselines directory would turn the capture check green with no review at all.
28
29
  *
29
- * Usage: visual-review gate --captures <dir> --base <ref> [--head <ref>], or node gate.mjs with
30
- * the same options. Standard library only (results.mjs and config.mjs have no dependencies), so
31
- * it runs from a checkout of this package without an install.
30
+ * Once visual-review/passkeys.json on the base branch holds a key, every record the pull request
31
+ * adds must also be version 2, for this pull request (`--pr`) or for none (a seed), with a passkey
32
+ * approval by one of the base branch's keys over exactly that record (approval.mjs), and not a
33
+ * copy of a record already on the base branch; a record that fails is reported and moves nothing.
34
+ * Keys are never read from the pull request, a pull request that would leave no key fails, and a
35
+ * change to passkeys.json itself needs an approved record like a baseline. Records already on the
36
+ * base branch are never checked again. Before that, records are read but not verified, so the gate
37
+ * proves a record names the change, not that the owner approved it (the README, "What the gate
38
+ * does and does not guarantee").
39
+ *
40
+ * CI runs this file as the base branch has it, never the pull request's copy, so a pull request
41
+ * cannot loosen the gate that judges it.
42
+ *
43
+ * Usage: visual-review gate --captures <dir> --base <ref> [--head <ref>] [--pr <number>], or node
44
+ * gate.mjs with the same options. Standard library only (results.mjs, config.mjs and approval.mjs
45
+ * have no dependencies), so it runs from a checkout of this package without an install.
32
46
  */
33
47
 
34
48
  import { execFileSync } from "node:child_process";
@@ -38,11 +52,18 @@ import { join } from "node:path";
38
52
  import { fileURLToPath } from "node:url";
39
53
  import { parseArgs } from "node:util";
40
54
 
55
+ import { PASSKEYS_FILE, parsePasskeys, recordHash, verifyRecord } from "./lib/approval.mjs";
41
56
  import { loadConfigAt, repoRoot } from "./lib/config.mjs";
42
57
  import { validateResults } from "./lib/results.mjs";
43
58
 
44
59
  const PASSING = new Set(["unchanged", "excluded"]);
45
60
 
61
+ /**
62
+ * The loosest diffThreshold the gate trusts, the highest a story here uses. At 1 pixelmatch counts
63
+ * no pixel as changed, so a settings file or a story's parameters could switch comparison off.
64
+ */
65
+ export const MAX_THRESHOLD = 0.8;
66
+
46
67
  /**
47
68
  * The newest attempt's results.json of every project in a directory of downloaded artifacts.
48
69
  * @param {string} dir the download directory, one subdirectory per artifact
@@ -88,8 +109,9 @@ export function gatedProjects(config, seeded, headConfig) {
88
109
 
89
110
  /**
90
111
  * What blocks the pull request.
91
- * @param {{ config: { defaultBranch: string, projects: Record<string, { seedFromDefaultBranch: boolean }> },
92
- * headConfig: { projects: Record<string, object> } | undefined, seeded: Set<string>,
112
+ * @param {{ config: { defaultBranch: string, baselines: string,
113
+ * projects: Record<string, { seedFromDefaultBranch: boolean }> },
114
+ * headConfig: { baselines: string, projects: Record<string, object> } | undefined, seeded: Set<string>,
93
115
  * captures: Record<string, { attempt: number, results: object | null }> }} input the base
94
116
  * branch's config, the pull request's config (if any), the projects with baselines on the base
95
117
  * branch, and the newest capture of each project
@@ -97,6 +119,13 @@ export function gatedProjects(config, seeded, headConfig) {
97
119
  */
98
120
  export function gateProblems({ config, headConfig, seeded, captures }) {
99
121
  const problems = [];
122
+ if (headConfig && headConfig.baselines !== config.baselines) {
123
+ // Capture reads the pull request's config, so its baselines would be compared, not the base's.
124
+ problems.push(
125
+ `this pull request moves the baselines directory from ${config.baselines} to ${headConfig.baselines}; ` +
126
+ "move it in a pull request of its own that changes nothing else, merged with an administrator's review",
127
+ );
128
+ }
100
129
  for (const p of gatedProjects(config, seeded, headConfig)) {
101
130
  const r = captures[p]?.results;
102
131
  if (!r) {
@@ -112,6 +141,13 @@ export function gateProblems({ config, headConfig, seeded, captures }) {
112
141
  problems.push(`${p}: the capture did not finish (${r.items.length} of ${r.expected} items); re-run it`);
113
142
  continue;
114
143
  }
144
+ const loose = r.items.filter((i) => i.threshold > MAX_THRESHOLD).length;
145
+ if (loose > 0) {
146
+ problems.push(
147
+ `${p}: ${loose} ${loose === 1 ? "item is" : "items are"} compared at a diffThreshold above ${MAX_THRESHOLD}, ` +
148
+ "which hides real changes; lower it in the story's parameters or its settings file",
149
+ );
150
+ }
115
151
  const open = r.items.map((i) => i.status).filter((s) => !PASSING.has(s));
116
152
  if (open.length > 0) {
117
153
  // No Object.groupBy: the gate runs on the runner's own Node, which may be 20.
@@ -152,18 +188,37 @@ function notSeeded(config, p) {
152
188
  const gitOut = (cwd, args) => execFileSync("git", args, { cwd, maxBuffer: 1 << 28 });
153
189
 
154
190
  /**
155
- * Baseline changes between two refs that no review record added between them accounts for.
191
+ * Baseline changes between two refs that the review records added between them do not account
192
+ * for. A changed PNG, story settings file (and, once approvals are enforced, passkeys.json) counts
193
+ * as reviewed only when the added records' items, replayed oldest `reviewedAt` first, take it from
194
+ * its hash on the base branch (null when absent) to its hash at the head (null when deleted); an
195
+ * item whose `from` is not the path's current hash moves nothing. A deleted settings file and
196
+ * renames.json need no record: the captures they cause are reviewed.
156
197
  * @param {string} base the base branch tip
157
198
  * @param {string} head the pull request's checkout
158
199
  * @param {string} [cwd] the repository
159
200
  * @param {string} [baselines] the baselines directory
201
+ * @param {{ keys?: object[] | null, pr?: number }} [approvals] with `keys`, every added record must
202
+ * carry a passkey approval by one of them for pull request `pr` (verifyRecord) and not copy a
203
+ * record already on the base branch, or it counts for nothing
160
204
  * @returns {string[]} one line per unaccounted change; empty when every change has a record
161
205
  */
162
- export function unrecordedChanges(base, head, cwd = process.cwd(), baselines = "visual-baselines") {
163
- const fields = gitOut(cwd, ["diff", "-z", "--no-renames", "--name-status", base, head, "--", `${baselines}/`])
206
+ export function unrecordedChanges(base, head, cwd = process.cwd(), baselines = "visual-baselines", approvals = {}) {
207
+ const enforced = Boolean(approvals.keys);
208
+ const fields = gitOut(cwd, [
209
+ "diff",
210
+ "-z",
211
+ "--no-renames",
212
+ "--name-status",
213
+ base,
214
+ head,
215
+ "--",
216
+ `${baselines}/`,
217
+ ...(enforced ? [PASSKEYS_FILE] : []),
218
+ ])
164
219
  .toString("utf8")
165
220
  .split("\0");
166
- const show = (path) => gitOut(cwd, ["show", `${head}:${path}`]);
221
+ const show = (ref, path) => gitOut(cwd, ["show", `${ref}:${path}`]);
167
222
  const problems = [];
168
223
  const records = [];
169
224
  const changed = [];
@@ -175,27 +230,57 @@ export function unrecordedChanges(base, head, cwd = process.cwd(), baselines = "
175
230
  } else {
176
231
  problems.push(`${path}: review records are append-only, but this one was changed or deleted`);
177
232
  }
178
- } else if (path.endsWith(".png")) {
179
- changed.push({ path, hash: status === "D" ? null : contentHash(show(path)) });
180
- } else if (path.endsWith(".json") && status !== "D") {
181
- // ponytail: only settings that exclude a story need a record. Any other settings edit,
182
- // and deleting a settings file, passes: the capture it causes is itself reviewed.
183
- const bytes = show(path);
184
- if (parseOr(bytes)?.disableSnapshot === true) {
185
- changed.push({ path, hash: sha256(bytes) });
186
- }
233
+ } else if (
234
+ path.endsWith(".png") ||
235
+ path === PASSKEYS_FILE ||
236
+ (path.endsWith(".json") && status !== "D" && !path.endsWith("/renames.json"))
237
+ ) {
238
+ const hash = path.endsWith(".png") ? contentHash : sha256;
239
+ changed.push({
240
+ path,
241
+ from: status === "A" ? null : hash(show(base, path)),
242
+ to: status === "D" ? null : hash(show(head, path)),
243
+ });
187
244
  }
188
245
  }
189
- const reviewed = new Set();
246
+ let onBase = null;
247
+ const entries = [];
190
248
  for (const path of records) {
191
- const items = parseOr(show(path))?.items;
192
- for (const item of Array.isArray(items) ? items : []) {
193
- reviewed.add(`${item?.path}\0${item?.to ?? null}`);
249
+ const record = parseOr(show(head, path));
250
+ if (enforced) {
251
+ let why = record === null ? "not valid JSON" : verifyRecord(record, approvals.keys, { pr: approvals.pr });
252
+ if (!why) {
253
+ onBase ??= baseRecordHashes(base, cwd, baselines);
254
+ if (onBase.has(recordHash(record).toString("hex"))) {
255
+ why = "a copy of a record already on the base branch: an approval counts once";
256
+ }
257
+ }
258
+ if (why) {
259
+ problems.push(`${path}: ${why}`);
260
+ continue;
261
+ }
194
262
  }
263
+ const items = record?.items;
264
+ entries.push({ at: String(record?.reviewedAt ?? ""), items: Array.isArray(items) ? items : [] });
195
265
  }
196
- const missing = changed.filter((c) => !reviewed.has(`${c.path}\0${c.hash}`)).map((c) => c.path);
197
- for (const path of missing.slice(0, 20)) {
198
- problems.push(`${path}: changed with no review record naming its new contents`);
266
+ entries.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
267
+ const missing = changed.filter((c) => {
268
+ let now = c.from;
269
+ for (const { items } of entries) {
270
+ for (const item of items) {
271
+ if (item?.path === c.path && (item.from ?? null) === now) {
272
+ now = item.to ?? null;
273
+ }
274
+ }
275
+ }
276
+ return now !== c.to;
277
+ });
278
+ for (const { path } of missing.slice(0, 20)) {
279
+ problems.push(
280
+ path === PASSKEYS_FILE
281
+ ? `${path}: changed with no record approved by a key on the base branch; once a key is registered, adding or replacing one is merged by an administrator (the README, "Replacing the passkey")`
282
+ : `${path}: changed with no review record taking it from its base branch contents to these`,
283
+ );
199
284
  }
200
285
  if (missing.length > 20) {
201
286
  problems.push(`... and ${missing.length - 20} more baseline files with no review record`);
@@ -203,6 +288,32 @@ export function unrecordedChanges(base, head, cwd = process.cwd(), baselines = "
203
288
  return problems;
204
289
  }
205
290
 
291
+ /**
292
+ * The record hashes of the version 2 records on the base branch.
293
+ * @param {string} base the base branch tip
294
+ * @param {string} cwd the repository
295
+ * @param {string} baselines the baselines directory
296
+ * @returns {Set<string>} hex hashes
297
+ */
298
+ function baseRecordHashes(base, cwd, baselines) {
299
+ const out = new Set();
300
+ const files = gitOut(cwd, ["ls-tree", "-r", "--name-only", base, "--", `${baselines}/reviews/`])
301
+ .toString("utf8")
302
+ .split("\n")
303
+ .filter(Boolean);
304
+ for (const path of files) {
305
+ const r = parseOr(gitOut(cwd, ["show", `${base}:${path}`]));
306
+ if (r?.version === 2) {
307
+ try {
308
+ out.add(recordHash(r).toString("hex"));
309
+ } catch {
310
+ // A number canonical() refuses: no approval can cover it, so nothing can copy it.
311
+ }
312
+ }
313
+ }
314
+ return out;
315
+ }
316
+
206
317
  const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
207
318
 
208
319
  /**
@@ -250,14 +361,90 @@ export function seededAt(ref, cwd = process.cwd(), baselines = "visual-baselines
250
361
  );
251
362
  }
252
363
 
253
- export const GATE_USAGE = `usage: visual-review gate --captures <dir> --base <ref> [--head <ref>]
364
+ /**
365
+ * A file at a git ref, or null when the ref does not hold it.
366
+ * @param {string} ref the commit
367
+ * @param {string} path the path
368
+ * @param {string} cwd the repository
369
+ * @returns {string | null} its text
370
+ */
371
+ function fileAt(ref, path, cwd) {
372
+ const listed = gitOut(cwd, ["ls-tree", "--name-only", ref, "--", path]).toString("utf8").trim();
373
+ return listed === "" ? null : gitOut(cwd, ["show", `${ref}:${path}`]).toString("utf8");
374
+ }
375
+
376
+ /**
377
+ * Whether approvals are enforced, and the keys they must come from: those of passkeys.json on the
378
+ * base branch, never the pull request's. Absent, or with no key, enforcement is off.
379
+ * @param {string} base the base branch tip
380
+ * @param {string} head the pull request's checkout
381
+ * @param {number | undefined} pr the pull request (`--pr`)
382
+ * @param {string} [cwd] the repository
383
+ * @returns {{ keys: object[] | null, problems: string[] }} the keys (null when off), and what fails
384
+ * the gate: an invalid base file, no `--pr`, or a pull request that would switch enforcement off
385
+ */
386
+ export function approvalKeys(base, head, pr, cwd = process.cwd()) {
387
+ const text = fileAt(base, PASSKEYS_FILE, cwd);
388
+ let keys = [];
389
+ try {
390
+ keys = text === null ? [] : parsePasskeys(text);
391
+ } catch (err) {
392
+ return { keys: null, problems: [`${PASSKEYS_FILE} on the base branch is invalid: ${err.message}`] };
393
+ }
394
+ if (keys.length === 0) {
395
+ return { keys: null, problems: [] };
396
+ }
397
+ const problems = [];
398
+ if (pr === undefined) {
399
+ problems.push(
400
+ `approvals are enforced (${PASSKEYS_FILE} on the base branch holds a key), so the gate needs --pr <number>`,
401
+ );
402
+ }
403
+ let left = 0;
404
+ try {
405
+ const own = fileAt(head, PASSKEYS_FILE, cwd);
406
+ left = own === null ? 0 : parsePasskeys(own).length;
407
+ } catch {
408
+ // Invalid: counts as no key.
409
+ }
410
+ if (left === 0) {
411
+ problems.push(
412
+ `this pull request would switch approval enforcement off: its ${PASSKEYS_FILE} is missing, invalid or holds no key`,
413
+ );
414
+ }
415
+ return { keys, problems };
416
+ }
417
+
418
+ /**
419
+ * The files a pull request could loosen the gate with: passkeys.json, the tool's trusted code and
420
+ * its capture, and the workflow that runs them. A change to one is a warning, never a failure (CI
421
+ * runs the base branch's copy of the code, and work on the tool changes it), so code review looks
422
+ * at it.
423
+ * @param {string} base the base branch tip
424
+ * @param {string} head the pull request's checkout
425
+ * @param {string} workflow the workflow file that runs the gate
426
+ * @param {string} [cwd] the repository
427
+ * @returns {string[]} the changed ones
428
+ */
429
+ export function trustFilesChanged(base, head, workflow, cwd = process.cwd()) {
430
+ const files = [PASSKEYS_FILE, "visual-review/trusted/", "visual-review/capture/", `.github/workflows/${workflow}`];
431
+ return gitOut(cwd, ["diff", "--name-only", base, head, "--", ...files])
432
+ .toString("utf8")
433
+ .split("\n")
434
+ .filter(Boolean);
435
+ }
436
+
437
+ export const GATE_USAGE = `usage: visual-review gate --captures <dir> --base <ref> [--head <ref>] [--pr <number>]
254
438
 
255
439
  Fails (exit 1) while a pull request holds visual changes nobody accepted, or a baseline change
256
- with no review record. Run it in CI after the capture jobs, on the pull request's merge commit.
440
+ with no review record. Once ${PASSKEYS_FILE} on the base branch holds a key, every review
441
+ record the pull request adds must also carry a passkey approval for this pull request. Run it in
442
+ CI after the capture jobs, on the pull request's merge commit.
257
443
 
258
444
  --captures <dir> the downloaded visual-<project>-<attempt> artifacts of this run
259
445
  --base <ref> the base branch tip (HEAD^1 on a pull request's merge commit)
260
- --head <ref> the pull request's checkout (default HEAD)`;
446
+ --head <ref> the pull request's checkout (default HEAD)
447
+ --pr <number> the pull request's number (required once approvals are enforced)`;
261
448
 
262
449
  /**
263
450
  * The gate as a command.
@@ -271,6 +458,7 @@ export function runGate(args) {
271
458
  captures: { type: "string" },
272
459
  base: { type: "string" },
273
460
  head: { type: "string", default: "HEAD" },
461
+ pr: { type: "string" },
274
462
  help: { type: "boolean", default: false },
275
463
  },
276
464
  });
@@ -278,7 +466,8 @@ export function runGate(args) {
278
466
  console.log(GATE_USAGE);
279
467
  return 0;
280
468
  }
281
- if (!values.captures || !values.base) {
469
+ const pr = values.pr === undefined ? undefined : Number(values.pr);
470
+ if (!values.captures || !values.base || (pr !== undefined && !(Number.isInteger(pr) && pr > 0))) {
282
471
  console.error(GATE_USAGE);
283
472
  return 2;
284
473
  }
@@ -290,11 +479,20 @@ export function runGate(args) {
290
479
  for (const line of problems) {
291
480
  console.log(`::error::visual changes not accepted -- ${line}`);
292
481
  }
293
- const unrecorded = unrecordedChanges(values.base, values.head, root, config.baselines);
482
+ const { keys, problems: approval } = approvalKeys(values.base, values.head, pr, root);
483
+ for (const line of approval) {
484
+ console.log(`::error::passkey approval -- ${line}`);
485
+ }
486
+ const unrecorded = unrecordedChanges(values.base, values.head, root, config.baselines, { keys, pr });
294
487
  for (const line of unrecorded) {
295
488
  console.log(`::error::baseline without a review -- ${line}`);
296
489
  }
297
- if (problems.length + unrecorded.length > 0) {
490
+ for (const file of trustFilesChanged(values.base, values.head, config.workflow, root)) {
491
+ console.log(
492
+ `::warning::this pull request changes ${file}, which decides what the visual gate accepts: review that change with care`,
493
+ );
494
+ }
495
+ if (problems.length + approval.length + unrecorded.length > 0) {
298
496
  console.log("Review them with `visual-review serve` (the @graphty/visual-review README).");
299
497
  return 1;
300
498
  }