@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.
@@ -154,6 +154,13 @@ export function storySettings(parameters, file) {
154
154
  name,
155
155
  globals: Object.fromEntries(Object.entries(m).filter(([k]) => k !== "disable")),
156
156
  }));
157
+ // A mode names files and results.json items, which allow only these: refused before any story
158
+ // is captured, not when results.json is checked at the end.
159
+ for (const { name } of modes) {
160
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name) || name.length > 50) {
161
+ throw new Error(`Chromatic mode "${name}": a mode name is lowercase letters, digits and "-", at most 50`);
162
+ }
163
+ }
157
164
  const where = byFile ? "settings file" : "story's parameters";
158
165
  return {
159
166
  disableSnapshot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphty/visual-review",
3
- "version": "0.2.0",
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/cli.mjs CHANGED
@@ -214,6 +214,7 @@ async function serve(args) {
214
214
  origin,
215
215
  masterRun,
216
216
  results: values.results && resolve(values.results),
217
+ warm: true,
217
218
  // What the owner types to run this server from their own shell, so Finish signs with
218
219
  // their key rather than the environment of whoever started it (an agent, say).
219
220
  startCommand:
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
@@ -63,7 +84,13 @@ export function newestResults(dir) {
63
84
  continue;
64
85
  }
65
86
  const file = join(dir, name, "results.json");
66
- out[project] = { attempt, results: existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null };
87
+ let results = null;
88
+ try {
89
+ results = JSON.parse(readFileSync(file, "utf8"));
90
+ } catch {
91
+ // Missing or not JSON: counted as missing, like any invalid results.json.
92
+ }
93
+ out[project] = { attempt, results };
67
94
  }
68
95
  return out;
69
96
  }
@@ -82,8 +109,9 @@ export function gatedProjects(config, seeded, headConfig) {
82
109
 
83
110
  /**
84
111
  * What blocks the pull request.
85
- * @param {{ config: { defaultBranch: string, projects: Record<string, { seedFromDefaultBranch: boolean }> },
86
- * 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>,
87
115
  * captures: Record<string, { attempt: number, results: object | null }> }} input the base
88
116
  * branch's config, the pull request's config (if any), the projects with baselines on the base
89
117
  * branch, and the newest capture of each project
@@ -91,6 +119,13 @@ export function gatedProjects(config, seeded, headConfig) {
91
119
  */
92
120
  export function gateProblems({ config, headConfig, seeded, captures }) {
93
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
+ }
94
129
  for (const p of gatedProjects(config, seeded, headConfig)) {
95
130
  const r = captures[p]?.results;
96
131
  if (!r) {
@@ -106,6 +141,13 @@ export function gateProblems({ config, headConfig, seeded, captures }) {
106
141
  problems.push(`${p}: the capture did not finish (${r.items.length} of ${r.expected} items); re-run it`);
107
142
  continue;
108
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
+ }
109
151
  const open = r.items.map((i) => i.status).filter((s) => !PASSING.has(s));
110
152
  if (open.length > 0) {
111
153
  // No Object.groupBy: the gate runs on the runner's own Node, which may be 20.
@@ -146,18 +188,37 @@ function notSeeded(config, p) {
146
188
  const gitOut = (cwd, args) => execFileSync("git", args, { cwd, maxBuffer: 1 << 28 });
147
189
 
148
190
  /**
149
- * 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.
150
197
  * @param {string} base the base branch tip
151
198
  * @param {string} head the pull request's checkout
152
199
  * @param {string} [cwd] the repository
153
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
154
204
  * @returns {string[]} one line per unaccounted change; empty when every change has a record
155
205
  */
156
- export function unrecordedChanges(base, head, cwd = process.cwd(), baselines = "visual-baselines") {
157
- 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
+ ])
158
219
  .toString("utf8")
159
220
  .split("\0");
160
- const show = (path) => gitOut(cwd, ["show", `${head}:${path}`]);
221
+ const show = (ref, path) => gitOut(cwd, ["show", `${ref}:${path}`]);
161
222
  const problems = [];
162
223
  const records = [];
163
224
  const changed = [];
@@ -169,27 +230,57 @@ export function unrecordedChanges(base, head, cwd = process.cwd(), baselines = "
169
230
  } else {
170
231
  problems.push(`${path}: review records are append-only, but this one was changed or deleted`);
171
232
  }
172
- } else if (path.endsWith(".png")) {
173
- changed.push({ path, hash: status === "D" ? null : contentHash(show(path)) });
174
- } else if (path.endsWith(".json") && status !== "D") {
175
- // ponytail: only settings that exclude a story need a record. Any other settings edit,
176
- // and deleting a settings file, passes: the capture it causes is itself reviewed.
177
- const bytes = show(path);
178
- if (parseOr(bytes)?.disableSnapshot === true) {
179
- changed.push({ path, hash: sha256(bytes) });
180
- }
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
+ });
181
244
  }
182
245
  }
183
- const reviewed = new Set();
246
+ let onBase = null;
247
+ const entries = [];
184
248
  for (const path of records) {
185
- const items = parseOr(show(path))?.items;
186
- for (const item of Array.isArray(items) ? items : []) {
187
- 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
+ }
188
262
  }
263
+ const items = record?.items;
264
+ entries.push({ at: String(record?.reviewedAt ?? ""), items: Array.isArray(items) ? items : [] });
189
265
  }
190
- const missing = changed.filter((c) => !reviewed.has(`${c.path}\0${c.hash}`)).map((c) => c.path);
191
- for (const path of missing.slice(0, 20)) {
192
- 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
+ );
193
284
  }
194
285
  if (missing.length > 20) {
195
286
  problems.push(`... and ${missing.length - 20} more baseline files with no review record`);
@@ -197,6 +288,32 @@ export function unrecordedChanges(base, head, cwd = process.cwd(), baselines = "
197
288
  return problems;
198
289
  }
199
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
+
200
317
  const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
201
318
 
202
319
  /**
@@ -244,14 +361,90 @@ export function seededAt(ref, cwd = process.cwd(), baselines = "visual-baselines
244
361
  );
245
362
  }
246
363
 
247
- 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>]
248
438
 
249
439
  Fails (exit 1) while a pull request holds visual changes nobody accepted, or a baseline change
250
- 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.
251
443
 
252
444
  --captures <dir> the downloaded visual-<project>-<attempt> artifacts of this run
253
445
  --base <ref> the base branch tip (HEAD^1 on a pull request's merge commit)
254
- --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)`;
255
448
 
256
449
  /**
257
450
  * The gate as a command.
@@ -265,6 +458,7 @@ export function runGate(args) {
265
458
  captures: { type: "string" },
266
459
  base: { type: "string" },
267
460
  head: { type: "string", default: "HEAD" },
461
+ pr: { type: "string" },
268
462
  help: { type: "boolean", default: false },
269
463
  },
270
464
  });
@@ -272,7 +466,8 @@ export function runGate(args) {
272
466
  console.log(GATE_USAGE);
273
467
  return 0;
274
468
  }
275
- 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))) {
276
471
  console.error(GATE_USAGE);
277
472
  return 2;
278
473
  }
@@ -284,11 +479,20 @@ export function runGate(args) {
284
479
  for (const line of problems) {
285
480
  console.log(`::error::visual changes not accepted -- ${line}`);
286
481
  }
287
- 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 });
288
487
  for (const line of unrecorded) {
289
488
  console.log(`::error::baseline without a review -- ${line}`);
290
489
  }
291
- 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) {
292
496
  console.log("Review them with `visual-review serve` (the @graphty/visual-review README).");
293
497
  return 1;
294
498
  }