mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-06 16:02:07 +00:00
263 lines
12 KiB
TypeScript
263 lines
12 KiB
TypeScript
// Publishes the last recorded run to the current branch's pull request.
|
|
// First publish: a Proof section appended to the PR description (stills inline, walkthrough videos last).
|
|
// Every later publish: a comment with before/after pairs for the stills that changed since the previous one.
|
|
//
|
|
// node scripts/publish.ts publish
|
|
// node scripts/publish.ts --dry-run write the body and print the gh command, upload nothing
|
|
// node scripts/publish.ts --no-video stills only
|
|
// node scripts/publish.ts --force comment even when no still changed
|
|
// node scripts/publish.ts --replace-body rewrite the PR description's Proof section instead of commenting
|
|
// node scripts/publish.ts --allow-failed publish a run with failing flows
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join, relative } from "node:path";
|
|
import pixelmatch from "pixelmatch";
|
|
import pngjs from "pngjs";
|
|
import { ARTIFACTS, assertLocal, QA_DIR, RUN_DIR } from "../lib/env.ts";
|
|
import type { RunInfo } from "../lib/global-setup.ts";
|
|
import type { Box, FlowMeta, Shot } from "../lib/proof.ts";
|
|
|
|
const { PNG } = pngjs;
|
|
const MARK = "<!-- warmbly-proof:start -->";
|
|
const MARK_END = "<!-- warmbly-proof:end -->";
|
|
const MAX_ATTACHMENTS = 50;
|
|
|
|
const flags = new Set(process.argv.slice(2));
|
|
for (const f of flags) {
|
|
if (!["--dry-run", "--no-video", "--force", "--replace-body", "--allow-failed"].includes(f)) die(`unknown flag ${f}`);
|
|
}
|
|
|
|
function die(msg: string): never {
|
|
console.error(`publish: ${msg}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
function git(...args: string[]): string {
|
|
return execFileSync("git", args, { cwd: QA_DIR, encoding: "utf8" }).trim();
|
|
}
|
|
|
|
function short(sha: string): string {
|
|
return sha.slice(0, 9);
|
|
}
|
|
|
|
type Baseline = { sha: string; shots: Record<string, string>; ignore?: Record<string, Box[]> };
|
|
type Pr = { number: number; body: string; url: string };
|
|
type Change = { flow: FlowMeta; shot: Shot; key: string; kind: "new" | "changed"; before?: string };
|
|
|
|
function loadRun(): { run: RunInfo; flows: FlowMeta[] } {
|
|
const runFile = join(RUN_DIR, "run.json");
|
|
if (!existsSync(runFile)) die("no recorded run; record one with `pnpm proof` first");
|
|
const run = JSON.parse(readFileSync(runFile, "utf8")) as RunInfo;
|
|
const flows = readdirSync(RUN_DIR, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory() && existsSync(join(RUN_DIR, d.name, "meta.json")))
|
|
.map((d) => JSON.parse(readFileSync(join(RUN_DIR, d.name, "meta.json"), "utf8")) as FlowMeta)
|
|
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
if (!flows.length) die("the last run recorded no flows");
|
|
return { run, flows };
|
|
}
|
|
|
|
function currentPr(): Pr {
|
|
const res = spawnSync("gh", ["pr", "view", "--json", "number,body,url"], { cwd: QA_DIR, encoding: "utf8" });
|
|
// A dry run previews the first publish before the PR exists.
|
|
if (res.status !== 0 && flags.has("--dry-run")) return { number: 0, body: "", url: "(no pull request yet)" };
|
|
if (res.status !== 0) die(`no pull request for this branch yet. Open it with \`gh pr create\`, then publish.\n${res.stderr.trim()}`);
|
|
return JSON.parse(res.stdout) as Pr;
|
|
}
|
|
|
|
// Blanks the regions a flow marked as changing on its own, in both images, before comparing.
|
|
function blank(img: InstanceType<typeof PNG>, boxes: Box[]): void {
|
|
for (const b of boxes) {
|
|
for (let y = Math.max(0, b.y); y < Math.min(img.height, b.y + b.height); y++) {
|
|
img.data.fill(0, (y * img.width + Math.max(0, b.x)) * 4, (y * img.width + Math.min(img.width, b.x + b.width)) * 4);
|
|
}
|
|
}
|
|
}
|
|
|
|
function differs(a: string, b: string, ignore: Box[]): boolean {
|
|
const x = PNG.sync.read(readFileSync(a));
|
|
const y = PNG.sync.read(readFileSync(b));
|
|
if (x.width !== y.width || x.height !== y.height) return true;
|
|
blank(x, ignore);
|
|
blank(y, ignore);
|
|
const changed = pixelmatch(x.data, y.data, undefined, x.width, x.height, { threshold: 0.1 });
|
|
return changed > Math.max(40, x.width * x.height * 0.0002);
|
|
}
|
|
|
|
function keyOf(flow: FlowMeta, shot: Shot): string {
|
|
return `${flow.slug}__${shot.name}`;
|
|
}
|
|
|
|
// Attachments are referenced in the body by the exact path passed to --attach, which gh rewrites to the upload.
|
|
class Attachments {
|
|
readonly dir = join(ARTIFACTS, "publish");
|
|
readonly paths: string[] = [];
|
|
|
|
constructor() {
|
|
rmSync(this.dir, { recursive: true, force: true });
|
|
mkdirSync(this.dir, { recursive: true });
|
|
}
|
|
|
|
add(src: string, name: string): string {
|
|
const dest = join(this.dir, name);
|
|
copyFileSync(src, dest);
|
|
const ref = `./${relative(QA_DIR, dest)}`;
|
|
this.paths.push(ref);
|
|
return ref;
|
|
}
|
|
}
|
|
|
|
function sentence(title: string): string {
|
|
return title.charAt(0).toUpperCase() + title.slice(1);
|
|
}
|
|
|
|
function image(alt: string, ref: string): string {
|
|
return `![${alt.replace(/[[\]]/g, "")}](${ref})`;
|
|
}
|
|
|
|
function initialSection(run: RunInfo, flows: FlowMeta[], files: Attachments, videos: FlowMeta[]): string {
|
|
const lines = [
|
|
MARK,
|
|
"## Proof",
|
|
"",
|
|
`Recorded at \`${short(run.sha)}\` against a local stack with seed data, 1920x1080.`,
|
|
"",
|
|
];
|
|
for (const flow of flows) {
|
|
lines.push(`### ${sentence(flow.title)}`, "");
|
|
for (const shot of flow.shots) lines.push(`**${shot.caption}**`, "", image(shot.caption, files.add(shot.file, `${flow.slug}-${shot.name}.png`)), "");
|
|
}
|
|
if (videos.length) {
|
|
lines.push("### Walkthrough", "", "Videos below, in this order:", "");
|
|
videos.forEach((f, i) => lines.push(`${i + 1}. ${sentence(f.title)}`));
|
|
lines.push("");
|
|
}
|
|
lines.push(MARK_END);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function updateComment(run: RunInfo, base: Baseline | undefined, changes: Change[], unchanged: number, videos: FlowMeta[], files: Attachments): string {
|
|
const lines: string[] = ["<!-- warmbly-proof:update -->"];
|
|
if (base) {
|
|
lines.push(`### Proof update: \`${short(base.sha)}..${short(run.sha)}\``, "");
|
|
try {
|
|
const subjects = git("log", "--format=%s", "--max-count=20", `${base.sha}..${run.sha}`).split("\n").filter(Boolean);
|
|
for (const s of subjects) lines.push(`- ${s}`);
|
|
if (subjects.length) lines.push("");
|
|
} catch {
|
|
// A rebase can drop the old commit; the range header still says what moved.
|
|
}
|
|
} else {
|
|
lines.push(`### Proof update at \`${short(run.sha)}\``, "", "No earlier publish is recorded on this machine, so every screen is shown.", "");
|
|
}
|
|
|
|
let flowTitle = "";
|
|
for (const c of changes) {
|
|
if (c.flow.title !== flowTitle) {
|
|
flowTitle = c.flow.title;
|
|
lines.push(`#### ${sentence(flowTitle)}`, "");
|
|
}
|
|
const after = files.add(c.shot.file, `${c.flow.slug}-${c.shot.name}.png`);
|
|
if (c.kind === "changed" && c.before) {
|
|
const before = files.add(c.before, `before-${c.flow.slug}-${c.shot.name}.png`);
|
|
lines.push(`**${c.shot.caption}** (changed)`, "", "| Before | After |", "| --- | --- |");
|
|
lines.push(`| ${image(`${c.shot.caption}, before`, before)} | ${image(`${c.shot.caption}, after`, after)} |`, "");
|
|
} else {
|
|
lines.push(`**${c.shot.caption}** (new)`, "", image(c.shot.caption, after), "");
|
|
}
|
|
}
|
|
if (unchanged) lines.push(`Unchanged: ${unchanged} screen${unchanged === 1 ? "" : "s"}.`, "");
|
|
if (videos.length) {
|
|
lines.push("Walkthrough videos below, in this order:", "");
|
|
videos.forEach((f, i) => lines.push(`${i + 1}. ${sentence(f.title)}`));
|
|
}
|
|
return lines.join("\n").trimEnd();
|
|
}
|
|
|
|
function saveBaseline(file: string, run: RunInfo, flows: FlowMeta[]): void {
|
|
const dir = join(file, "..", "shots");
|
|
rmSync(dir, { recursive: true, force: true });
|
|
mkdirSync(dir, { recursive: true });
|
|
const shots: Record<string, string> = {};
|
|
const ignore: Record<string, Box[]> = {};
|
|
for (const flow of flows) {
|
|
for (const shot of flow.shots) {
|
|
const key = keyOf(flow, shot);
|
|
shots[key] = join(dir, `${key}.png`);
|
|
copyFileSync(shot.file, shots[key]);
|
|
if (shot.ignore) ignore[key] = shot.ignore;
|
|
}
|
|
}
|
|
writeFileSync(file, JSON.stringify({ sha: run.sha, shots, ignore } satisfies Baseline, null, 2));
|
|
}
|
|
|
|
function main(): void {
|
|
const { run, flows } = loadRun();
|
|
assertLocal(run.webURL);
|
|
|
|
const failed = flows.filter((f) => f.status !== "passed");
|
|
if (failed.length && !flags.has("--allow-failed")) {
|
|
die(`flows failed, fix them before publishing: ${failed.map((f) => f.title).join(", ")}`);
|
|
}
|
|
const unencoded = flows.filter((f) => f.frames);
|
|
if (unencoded.length) die(`the run ended before its videos were encoded (${unencoded.map((f) => f.title).join(", ")}); record again`);
|
|
const head = git("rev-parse", "HEAD");
|
|
if (run.sha !== head) die(`the recording is from ${short(run.sha)} but HEAD is ${short(head)}; record again`);
|
|
if (run.dirty) console.warn("publish: warning: the recording was made with uncommitted changes in the tree");
|
|
if (!git("branch", "-r", "--contains", head)) console.warn("publish: warning: HEAD is not pushed yet, so reviewers cannot see this commit");
|
|
|
|
const pr = currentPr();
|
|
const baselineFile = join(ARTIFACTS, "published", `pr-${pr.number}`, "state.json");
|
|
const base = existsSync(baselineFile) ? (JSON.parse(readFileSync(baselineFile, "utf8")) as Baseline) : undefined;
|
|
const files = new Attachments();
|
|
const withVideo = (list: FlowMeta[]) => (flags.has("--no-video") ? [] : list.filter((f) => f.video && existsSync(f.video)));
|
|
const bodyFile = join(files.dir, "body.md");
|
|
let args: string[];
|
|
|
|
const firstPublish = !pr.body?.includes(MARK);
|
|
if (firstPublish || flags.has("--replace-body")) {
|
|
const videos = withVideo(flows);
|
|
const kept = (pr.body ?? "").split(MARK)[0].trimEnd();
|
|
writeFileSync(bodyFile, `${kept ? `${kept}\n\n` : ""}${initialSection(run, flows, files, videos)}\n`);
|
|
for (const f of videos) files.add(f.video!, `${f.slug}.mp4`);
|
|
args = ["pr", "edit", String(pr.number), "--body-file", bodyFile];
|
|
} else {
|
|
const changes: Change[] = [];
|
|
let unchanged = 0;
|
|
for (const flow of flows) {
|
|
for (const shot of flow.shots) {
|
|
const key = keyOf(flow, shot);
|
|
const before = base?.shots[key];
|
|
if (!before || !existsSync(before)) changes.push({ flow, shot, key, kind: "new" });
|
|
else if (differs(before, shot.file, [...(base?.ignore?.[key] ?? []), ...(shot.ignore ?? [])])) changes.push({ flow, shot, key, kind: "changed", before });
|
|
else unchanged++;
|
|
}
|
|
}
|
|
if (!changes.length && !flags.has("--force")) {
|
|
console.log(`publish: no screen changed since ${base ? short(base.sha) : "the last publish"}; nothing posted (--force posts anyway)`);
|
|
return;
|
|
}
|
|
const changedFlows = new Set(changes.map((c) => c.flow.slug));
|
|
const videos = withVideo(flows.filter((f) => changedFlows.has(f.slug) || flags.has("--force")));
|
|
writeFileSync(bodyFile, `${updateComment(run, base, changes, unchanged, videos, files)}\n`);
|
|
for (const f of videos) files.add(f.video!, `${f.slug}.mp4`);
|
|
args = ["pr", "comment", String(pr.number), "--body-file", bodyFile];
|
|
}
|
|
|
|
if (files.paths.length > MAX_ATTACHMENTS) {
|
|
die(`${files.paths.length} attachments is over gh's limit of ${MAX_ATTACHMENTS}; record fewer flows or pass --no-video`);
|
|
}
|
|
for (const p of files.paths) args.push("--attach", p);
|
|
|
|
if (flags.has("--dry-run")) {
|
|
console.log(readFileSync(bodyFile, "utf8"));
|
|
console.log(`\n(dry run) gh ${args.join(" ")}`);
|
|
return;
|
|
}
|
|
const res = spawnSync("gh", args, { cwd: QA_DIR, stdio: "inherit" });
|
|
if (res.status !== 0) die("gh failed; nothing was recorded as published");
|
|
saveBaseline(baselineFile, run, flows);
|
|
console.log(`publish: ${firstPublish || flags.has("--replace-body") ? "description updated" : "comment posted"} on ${pr.url}`);
|
|
}
|
|
|
|
main();
|