Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/scripts/check-pr-captures.mjs @miguel-heygen
/scripts/check-pr-captures.test.mjs @miguel-heygen
/.github/workflows/pr-captures.yml @miguel-heygen
32 changes: 32 additions & 0 deletions .github/workflows/pr-captures.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: PR captures

permissions:
contents: read
pull-requests: read

# Runs on every PR (no path filter) so it can be a required check; the script passes when neither package changed.
on:
pull_request:
types: [opened, synchronize, reopened, edited]
branches: [main]

concurrency:
group: pr-captures-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
captures:
name: Studio and player captures
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Require Before and After captures
env:
PR_BODY: ${{ github.event.pull_request.body }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node scripts/check-pr-captures.mjs --base "origin/$BASE_REF" --head "$HEAD_SHA"
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts && vitest run scripts/catalog/",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts && vitest run scripts/catalog/",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "node --test 'skills/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
Expand Down
243 changes: 243 additions & 0 deletions scripts/check-pr-captures.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
#!/usr/bin/env node
// Fail a PR touching packages/studio or packages/player unless its body has Before and After sections with media.
// usage: node scripts/check-pr-captures.mjs --base origin/main --head <sha>; the body arrives in the env (see main).

import { execFileSync } from "node:child_process";
import { realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";

export const WATCHED_PREFIXES = ["packages/studio/", "packages/player/"];
export const NO_VISIBLE_CHANGE_MAX_LINES = 20;
export const VISUAL_EXTENSIONS = [".tsx", ".css", ".html"];

const MEDIA_PATH = /\.(?:png|jpe?g|gif|webp|svg|mp4|mov|webm)$/i;
const ATTACHMENT_PATH = /^\/user-attachments\/assets\/[\w-]+/;
const IMAGE_EMBED =
/!\[[^\]]*\]\(\s*https?:\/\/|<(?:img|video|source)\b[^>]*\bsrc=["']https?:\/\//i;
const ANY_URL = /https?:\/\/[^\s)"'<>\]]+/gi;

function parseUrl(raw) {
try {
return new URL(raw);
} catch {
return null;
}
}

const isAttachmentUrl = (url) =>
url.hostname === "github.com" && ATTACHMENT_PATH.test(url.pathname);

function isMediaUrl(raw) {
const url = parseUrl(raw);
return url !== null && (isAttachmentUrl(url) || MEDIA_PATH.test(url.pathname));
}

const TEST_FILE = /\.(?:test|spec)\.[jt]sx?$/;

const CAPTURE_TITLES = {
before: /^before(?:\s*[:(].*|\s+[-–—]\s.*)?$/i,
after: /^after(?:\s*[:(].*|\s+[-–—]\s.*)?$/i,
noVisibleChange: /^no visible change\b/i,
};

const isCapture = (title) => Object.values(CAPTURE_TITLES).some((re) => re.test(title));

const SETEXT = [
[/^=+\s*$/, "#"],
[/^-{2,}\s*$/, "##"],
];

/** GitHub hides an unclosed comment to the end of the body; repeat so removal cannot join fragments into a new one. */
function stripComments(body) {
let text = body;
let previous;
do {
previous = text;
text = text.replace(/<!--[\s\S]*?(?:-->|$)/g, "");
} while (text !== previous);
return text;
}

const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;

const closesFence = (open, line) => {
const [, marker = "", rest = ""] = FENCE.exec(line) ?? [];
return marker[0] === open[0] && marker.length >= open.length && rest.trim() === "";
};

const openingMarker = (line) => FENCE.exec(line)?.[1] ?? null;

/** The open fence marker after this line (CommonMark: same character, at least as long, to close), or null. */
const nextFence = (open, line) => {
if (open) return closesFence(open, line) ? null : open;
return openingMarker(line);
};

function blankFences(lines) {
let open = null;
return lines.map((line) => {
const before = open;
open = nextFence(open, line);
return before || open ? "" : line;
});
}

const setextPrefix = (underline) => SETEXT.find(([re]) => re.test(underline))?.[1];

function promoteSetext(lines) {
const padded = [...lines, ""];
return lines.map((line, i) => {
const prefix = isCapture(line.trim()) ? setextPrefix(padded[i + 1]) : undefined;
return prefix ? `${prefix} ${line.trim()}` : line;
});
}

/** Drop HTML comments and fenced code, and promote setext capture headings, so none can fake a section. */
const normalize = (body) => promoteSetext(blankFences(stripComments(body).split(/\r?\n/)));

/** Sections run to the next heading of the same or a higher level, or to the next capture heading. */
export function parseSections(body) {
const lines = normalize(body);
const headings = [];
lines.forEach((line, index) => {
const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
if (match) headings.push({ level: match[1].length, title: match[2].trim(), index });
});
return headings.map((heading, i) => {
const end = headings
.slice(i + 1)
.find((next) => next.level <= heading.level || isCapture(next.title));
return {
title: heading.title,
text: lines.slice(heading.index + 1, end ? end.index : lines.length).join("\n"),
};
});
}

export function hasMedia(text) {
return IMAGE_EMBED.test(text) || (text.match(ANY_URL) ?? []).some(isMediaUrl);
}

function findSection(sections, name) {
return sections.find((section) => CAPTURE_TITLES[name].test(section.title));
}

function parseRecord(record) {
const [added, deleted, ...rest] = record.split("\t");
const binary = added === "-" || deleted === "-";
const lines = binary ? NO_VISIBLE_CHANGE_MAX_LINES : Number(added) + Number(deleted);
return { path: rest.join("\t"), lines };
}

const isWatched = (path) =>
path !== "" && WATCHED_PREFIXES.some((prefix) => path.startsWith(prefix));

/** Parse `git diff --numstat -z --no-renames`, keeping watched paths. A binary file counts as a full budget. */
export const parseNumstat = (numstat) =>
numstat
.split("\0")
.map(parseRecord)
.filter((file) => isWatched(file.path));

const isVisualFile = (path) =>
VISUAL_EXTENSIONS.some((ext) => path.endsWith(ext)) && !TEST_FILE.test(path);

/** Why a "No visible change" declaration does not hold for this diff; empty means it holds. */
export function noVisibleChangeFailures(files) {
const failures = [];
const lines = files.reduce((sum, file) => sum + file.lines, 0);
if (lines >= NO_VISIBLE_CHANGE_MAX_LINES) {
failures.push(
`the diff changes ${lines} lines under packages/studio and packages/player; the limit is under ${NO_VISIBLE_CHANGE_MAX_LINES}`,
);
}
const visual = files.filter((file) => isVisualFile(file.path));
if (visual.length > 0) {
failures.push(
`the diff touches ${VISUAL_EXTENSIONS.join(", ")} files: ${visual.map((file) => file.path).join(", ")}`,
);
}
return failures;
}

function sectionProblem(section, name) {
if (!section) return `the body has no "${name}" heading`;
return hasMedia(section.text) ? null : `the "${name}" section has no image or video`;
}

const captureProblems = (sections) =>
[
sectionProblem(findSection(sections, "before"), "Before"),
sectionProblem(findSection(sections, "after"), "After"),
].filter(Boolean);

function noVisibleChangeVerdict(sections, files) {
if (!findSection(sections, "noVisibleChange")) return { holds: false, problems: [] };
const failures = noVisibleChangeFailures(files);
const problems = failures.map((failure) => `"No visible change" does not apply: ${failure}`);
return { holds: failures.length === 0, problems };
}

const PASS = { ok: true, problems: [] };

export function evaluate({ body, files }) {
if (files.length === 0) return PASS;
const sections = parseSections(body);
const captures = captureProblems(sections);
if (captures.length === 0) return PASS;
const verdict = noVisibleChangeVerdict(sections, files);
if (verdict.holds) return PASS;
return { ok: false, problems: [...verdict.problems, ...captures] };
}

export function attachCommand(prNumber) {
return `gh pr edit ${prNumber} --attach ./before.png --attach ./after.png`;
}

function flag(args, name, fallback) {
const at = args.indexOf(name);
return at === -1 ? fallback : args[at + 1];
}

function readNumstat(base, head) {
try {
return execFileSync("git", ["diff", "--numstat", "-z", "--no-renames", `${base}...${head}`], {
encoding: "utf8",
});
} catch (error) {
console.error(`cannot diff ${base}...${head}: ${error.message.trim()}`);
return process.exit(2);
}
}

function printFailure(problems, prNumber) {
console.error(
"This PR changes packages/studio or packages/player, so its body must show the behaviour.",
);
for (const problem of problems) console.error(` - ${problem}`);
console.error("\nAdd '## Before' and '## After' sections, each with an image or video, then:");
console.error(` ${attachCommand(prNumber)}`);
console.error("Only the PR description counts; captures posted as comments are not read.");
console.error(
"Edit the body text first: gh pr edit --body-file replaces the body and drops attachments.",
);
console.error(
`A change with no visible effect (under ${NO_VISIBLE_CHANGE_MAX_LINES} lines, no .tsx/.css/.html) may instead add a '## No visible change' section.`,
);
}

function main() {
const args = process.argv.slice(2);
const numstat = readNumstat(flag(args, "--base", "origin/main"), flag(args, "--head", "HEAD"));
const body = process.env.PR_BODY ?? "";
const { ok, problems } = evaluate({ body, files: parseNumstat(numstat) });
if (ok) {
console.log("packages/studio and packages/player: captures present, or nothing to show.");
return;
}
printFailure(problems, process.env.PR_NUMBER ?? "<number>");
process.exit(1);
}

if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href)
main();
Loading
Loading