Skip to content

Commit 458a515

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat: add deterministic visual-change detection for PRs
1 parent ecf2ae2 commit 458a515

34 files changed

Lines changed: 3575 additions & 28 deletions
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
name: Design diff (advisory)
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, reopened, synchronize, edited, ready_for_review]
6+
branches: [staging]
7+
workflow_dispatch:
8+
inputs:
9+
pull_request:
10+
description: Open PR number targeting staging
11+
required: true
12+
type: number
13+
14+
permissions:
15+
contents: read
16+
pull-requests: read
17+
18+
concurrency:
19+
group: design-diff-${{ github.event.pull_request.number || inputs.pull_request }}
20+
cancel-in-progress: true
21+
22+
jobs:
23+
analyze:
24+
if: github.event_name == 'pull_request_target' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
25+
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
26+
timeout-minutes: 15
27+
steps:
28+
- name: Resolve immutable trusted engine and PR revisions
29+
id: revisions
30+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
31+
env:
32+
REQUESTED_PR: ${{ inputs.pull_request }}
33+
with:
34+
script: |
35+
const { owner, repo } = context.repo;
36+
const number = context.payload.pull_request?.number ?? Number(process.env.REQUESTED_PR);
37+
if (!Number.isSafeInteger(number) || number <= 0) throw new Error('Invalid PR number');
38+
const pr = context.payload.pull_request ?? (await github.rest.pulls.get({ owner, repo, pull_number: number })).data;
39+
if (pr.state !== 'open' || pr.base.ref !== 'staging' || pr.base.repo.full_name !== `${owner}/${repo}`) {
40+
throw new Error('Expected an open PR targeting this repository staging branch');
41+
}
42+
const repository = (await github.rest.repos.get({ owner, repo })).data;
43+
const engine = (await github.rest.repos.getCommit({ owner, repo, ref: repository.default_branch })).data.sha;
44+
for (const sha of [engine, pr.base.sha, pr.head.sha]) {
45+
if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error('Invalid commit identity');
46+
}
47+
core.setOutput('engine', engine);
48+
core.setOutput('base', pr.base.sha);
49+
core.setOutput('head', pr.head.sha);
50+
core.setOutput('pr', String(number));
51+
52+
- name: Checkout trusted engine only
53+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
54+
with:
55+
ref: ${{ steps.revisions.outputs.engine }}
56+
fetch-depth: 0
57+
persist-credentials: false
58+
59+
- name: Setup Bun
60+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
61+
with:
62+
bun-version: 1.4.1
63+
64+
- name: Install trusted dependencies
65+
run: bun install --frozen-lockfile --ignore-scripts
66+
67+
- name: Fetch PR source as Git objects
68+
env:
69+
BASE_SHA: ${{ steps.revisions.outputs.base }}
70+
HEAD_SHA: ${{ steps.revisions.outputs.head }}
71+
GH_TOKEN: ${{ github.token }}
72+
run: |
73+
AUTH_HEADER="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')"
74+
git -c "http.extraheader=AUTHORIZATION: basic $AUTH_HEADER" fetch --no-tags origin "$BASE_SHA" "$HEAD_SHA" >/dev/null 2>&1
75+
test "$(git rev-parse --verify "$BASE_SHA^{commit}")" = "$BASE_SHA"
76+
test "$(git rev-parse --verify "$HEAD_SHA^{commit}")" = "$HEAD_SHA"
77+
78+
- name: Analyze source
79+
env:
80+
BASE_SHA: ${{ steps.revisions.outputs.base }}
81+
HEAD_SHA: ${{ steps.revisions.outputs.head }}
82+
DESIGN_DIFF_PR: ${{ steps.revisions.outputs.pr }}
83+
DESIGN_DIFF_ENGINE_SHA: ${{ steps.revisions.outputs.engine }}
84+
REPORT_PATH: ${{ runner.temp }}/design-diff-${{ steps.revisions.outputs.pr }}-${{ steps.revisions.outputs.head }}.json
85+
run: bun run design:diff --base "$BASE_SHA" --head "$HEAD_SHA" --output "$REPORT_PATH" > /dev/null 2>&1
86+
87+
- name: Retain JSON report
88+
if: ${{ always() && steps.revisions.outcome == 'success' }}
89+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
90+
with:
91+
name: design-diff-${{ steps.revisions.outputs.pr }}-${{ steps.revisions.outputs.head }}
92+
path: ${{ runner.temp }}/design-diff-${{ steps.revisions.outputs.pr }}-${{ steps.revisions.outputs.head }}.json
93+
retention-days: 7
94+
if-no-files-found: error

bun.lock

Lines changed: 42 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

design-diff.config.json

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
{
2+
"sourceRoots": ["apps/", "packages/"],
3+
"exclude": [
4+
"(?:^|/)(?:node_modules|__tests__|__fixtures__|fixtures|test-results|\\.source|dist|build)/",
5+
"\\.(?:test|spec)\\.[cm]?[jt]sx?$",
6+
"(?:^|/)next-env\\.d\\.ts$"
7+
],
8+
"renderedMarkdown": ["apps/docs/content/", "apps/sim/content/"],
9+
"aliases": [
10+
{
11+
"from": "apps/sim/",
12+
"prefix": "@/",
13+
"target": "apps/sim/"
14+
},
15+
{
16+
"from": "apps/docs/",
17+
"prefix": "@/",
18+
"target": "apps/docs/"
19+
}
20+
],
21+
"themes": [
22+
{
23+
"roots": ["apps/sim/", "packages/emcn/", "packages/workflow-renderer/"],
24+
"path": "apps/sim/app/_styles/globals.css"
25+
},
26+
{
27+
"roots": ["apps/docs/", "packages/emcn/", "packages/workflow-renderer/"],
28+
"path": "apps/docs/app/global.css"
29+
}
30+
],
31+
"classFunctions": ["cn", "clsx", "classNames", "twMerge"],
32+
"variantFunctions": ["cva"],
33+
"nativeAppearance": [
34+
"backgroundColor",
35+
"titleBarStyle",
36+
"titleBarOverlay",
37+
"trafficLightPosition",
38+
"vibrancy",
39+
"visualEffectState",
40+
"transparent",
41+
"opacity",
42+
"frame",
43+
"roundedCorners",
44+
"backgroundMaterial",
45+
"width",
46+
"height",
47+
"minWidth",
48+
"minHeight",
49+
"maxWidth",
50+
"maxHeight",
51+
"resizable",
52+
"fullscreen",
53+
"autoHideMenuBar",
54+
"icon"
55+
],
56+
"infrastructure": [
57+
"(?:^|/)(?:tailwind|postcss|next|vite|electron-vite|source)\\.config\\.",
58+
"(?:^|/)mdx-components\\.",
59+
"(?:^|/)lib/postcss/",
60+
"(?:^|/)lib/cn\\.ts$",
61+
"(?:^|/)tsconfig[^/]*\\.json$"
62+
],
63+
"renderingDependencies": "^(?:react(?:-dom)?|next|tailwindcss|tailwind-merge|clsx|class-variance-authority|postcss|electron|framer-motion|motion|tw-animate-css|@tailwindcss/|@radix-ui/|@react-email/|fumadocs|@mdx-js/|remark-|rehype-|lucide)",
64+
"limits": {
65+
"fileBytes": 2097152,
66+
"totalBytes": 268435456,
67+
"resolutionDepth": 24,
68+
"resolutionSteps": 5000
69+
},
70+
"nativeRendering": [
71+
"apps/desktop/src/main/terminal-themes.ts",
72+
"apps/desktop/src/main/context-menu.ts",
73+
"apps/desktop/src/main/tray.ts"
74+
],
75+
"classModules": [
76+
"clsx",
77+
"classnames",
78+
"tailwind-merge",
79+
"@sim/emcn/lib/cn",
80+
"@/lib/utils",
81+
"@/lib/cn"
82+
],
83+
"variantModules": ["class-variance-authority"],
84+
"mergeFontSizes": ["micro", "caption", "small", "md"]
85+
}

package.json

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@
114114
"test:workflow-sync": "bun --no-env-file scripts/test-workflow-sync.ts",
115115
"type-check": "turbo run type-check",
116116
"release": "bun run scripts/create-single-release.ts",
117-
"test:scripts": "vitest run --config vitest.scripts.config.ts"
117+
"test:scripts": "vitest run --config vitest.scripts.config.ts",
118+
"design:diff": "bun --no-env-file scripts/design-diff/cli.ts",
119+
"check:design-diff-types": "tsc --noEmit --project scripts/design-diff/tsconfig.json"
118120
},
119121
"overrides": {
120122
"react": "19.2.4",
@@ -150,9 +152,13 @@
150152
},
151153
"devDependencies": {
152154
"@babel/parser": "7.29.2",
155+
"@babel/traverse": "7.29.0",
156+
"@babel/types": "7.29.7",
153157
"@biomejs/biome": "2.0.6",
154158
"@octokit/rest": "^21.0.0",
155159
"@sim/utils": "workspace:*",
160+
"@types/babel__traverse": "7.28.0",
161+
"@types/node": "24.2.1",
156162
"@types/opentype.js": "1.3.10",
157163
"@typescript/native": "npm:typescript@^7.0.2",
158164
"@typescript/typescript6": "^6.0.2",
@@ -165,12 +171,19 @@
165171
"gray-matter": "4.0.3",
166172
"husky": "9.1.7",
167173
"json-schema-to-typescript": "15.0.4",
174+
"jsonc-parser": "3.3.1",
168175
"lint-staged": "16.0.0",
169176
"opentype.js": "1.3.4",
177+
"parse5": "7.3.0",
178+
"postcss": "8.5.26",
170179
"react": "19.2.4",
180+
"remark-frontmatter": "5.0.0",
171181
"remark-gfm": "4.0.1",
182+
"remark-mdx": "3.1.1",
172183
"remark-parse": "11.0.0",
173184
"sharp": "0.35.4",
185+
"tailwind-merge": "3.6.0",
186+
"tailwindcss": "4.3.3",
174187
"turbo": "2.9.14",
175188
"unified": "11.0.5",
176189
"unist-util-visit": "5.1.0",
@@ -189,5 +202,8 @@
189202
"patchedDependencies": {
190203
"@better-auth/oauth-provider@1.6.27": "patches/@better-auth%2Foauth-provider@1.6.27.patch",
191204
"postgres@3.4.9": "patches/postgres@3.4.9.patch"
205+
},
206+
"imports": {
207+
"#design-diff/*": "./scripts/design-diff/*.ts"
192208
}
193209
}

scripts/check-script-test-coverage.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env bun
22
/**
3-
* Asserts every `scripts/*.test.ts` file is collected by the root Vitest config.
3+
* Asserts every root script test and nested design-diff test is collected by the root Vitest config.
44
*
55
* The root `test` script once chained a hand-maintained list of `test:*` entries, and a
66
* hand-maintained list silently drifts from the files on disk: a test added without a matching
@@ -57,10 +57,14 @@ const collected = new Set(
5757
)
5858
)
5959

60-
const onDisk = readdirSync(path.join(ROOT, 'scripts'))
61-
.filter((file) => file.endsWith('.test.ts'))
62-
.map((file) => `scripts/${file}`)
63-
.sort()
60+
const onDisk = [
61+
...readdirSync(path.join(ROOT, 'scripts'))
62+
.filter((file) => file.endsWith('.test.ts'))
63+
.map((file) => `scripts/${file}`),
64+
...readdirSync(path.join(ROOT, 'scripts/design-diff/tests'), { recursive: true })
65+
.filter((file): file is string => typeof file === 'string' && file.endsWith('.test.ts'))
66+
.map((file) => `scripts/design-diff/tests/${file.split(path.sep).join('/')}`),
67+
].sort()
6468

6569
const orphaned = onDisk.filter((file) => !collected.has(file))
6670
if (orphaned.length > 0) {

0 commit comments

Comments
 (0)