Skip to content

ci: the PR report shows before/after/diff thumbnails for visual changes, from frames a frames-only job publishes - #9201

Merged
cixzhang merged 19 commits into
mainfrom
ci/pr-report-frames
Oct 31, 2026
Merged

cixzhang merged 19 commits into
mainfrom
ci/pr-report-frames

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

A reviewer of a visual change today opens the run's visual report to see what moved. With this, the PR report's collapsed "Visual changes" section shows Before / After / Diff thumbnails for the 12 largest changes, so the reviewer sees the change inline.

Intended invariant

  • When the visual run changed shots, the report shows each listed shot's thumbnails: before, after and diff for a change, after only for an added shot, before only for a removed one. A frame that was not published says "not published"; nothing else is linked.
  • Without published frames, the section lists the changes as it does in ci: the PR report becomes one compact view: this head's preview, a modified-components table, collapsed visual changes, package size, and one not-run line #9171.
  • Security:
    • A new frames job is the only job in the workflow with contents: write. It runs trusted code from main, never checks out or runs pull-request code, and does not persist credentials in its checkout.
    • publish-frames.mjs writes only refs/heads/assets/pr-<n>. It publishes at most 12 shots, and only PNG or WebP frames of at most 500 KB each, checked by their bytes, with sanitized names.
    • The comment job keeps no write access to contents. It renders frames only from this pull request's assets branch for this exact head, and only the frame paths the frames job reports.
  • Cleanup: a branch the frames job creates carries a .pr-report-frames marker. The daily branch pruner deletes a marked assets/pr-<n> branch once PR #n is merged or closed. Hand-pushed assets branches (screenshots) have no marker and keep the existing rule, so their links don't break.

Change

  • visual-gate/publish-frames.mjs (new): takes the report's own rows (visualShots in lib/pr-status.js, so thumbnails can't drift from the rows), validates the frames, and commits them in one commit under visual/<head sha>/. It writes image_base and the published frame paths to the job outputs.
  • pr-comment.yml: the new frames job (download, re-encode as the comment job does, publish). The comment job waits for it and passes its outputs on, and the outcome report counts it.
  • lib/pr-preview.mjs: trustedFrames checks the URL against this PR's number and head, and each path against <before|after|diff>/<key>.png.
  • lib/pr-status.js: visualShots, shared by the rows and the frames, and thumbnails for published frames only.
  • lib/visual-format.js and its test are removed. Its Before / After / Diff renderer has had no caller since the compact report.
  • scripts/prune-branches.sh: the marker rule for assets/pr-<n>.

Evidence

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

  • vitest run --project node .github/scripts scripts/prune-branches.test.mjs: all pass except the known Chromium-launch and load-timeout tests on this machine (rtl-audit-cli, analyze-pr), which are unrelated.
  • publish-frames.test.mjs:
    • frame choice: largest first, at most 12, unsafe keys dropped;
    • only PNG/WebP of at most 500 KB, and a non-image skipped;
    • writes only assets/pr-<n>, and marks only a branch it creates;
    • reports the published frame paths.
  • pr-preview.test.mjs:
    • frames are accepted only for this PR's number and head, and only <before|after|diff>/<key>.png paths;
    • only the frames job has contents: write, and its checkout persists no credentials.
  • pr-status.test.mjs: thumbnails only for published frames.
  • prune-branches.test.mjs: a merged PR's marked branch is deleted, an open PR's is kept, and an unmarked one is not deleted even though its PR is closed.
  • Mutation checks, each caught:
    • dropping the PR-number check from the frame URL;
    • dropping the marker check from the pruner.

…h PR, in the build job

Measures every published package's npm download and unpacked size in
build-storybook, after its build, and compares it with the merge base's
measurement that Main now keeps for 14 days. Adds a Published Package Size
section to the PR report and the job summary, and fails the job when a
package's download grows more than 5% (and at least 1 KB) unless the PR
description has a 'Size budget:' line.
…e ci:heavy-lanes

Each label opts a pull request into one heavy suite. A run-labels gate job
reads the event's labels and, for each suite label, the newest labeler's
role (write, maintain or admin; fail closed), from a decision script in the
merge commit's sparse checkout; each suite's owners need it and run only on
their own suite's output. A label still takes effect on the next push or
reopen, ci:run stays the fork gate, and the release check is unchanged.
The pull-request comment names the label for a suite that did not run.
AST-030, CONTRIBUTING and the visual and RTL docs name the new labels.
…ize, visual, accessibility, RTL, perf when it ran, and the CI jobs; touched components link their docs and stories
…helper; the check also follows root build inputs

- A package over its budget is flagged (job summary, PR report, a workflow
  warning) and fails nothing, like the component touch-only flags; the
  'Size budget:' line and the enforce step are gone.
- The package-size merge-base step deepens once when the 50-commit fetch
  holds no merge base, as size-base does.
- Report counts render through num().
- tsconfig.json and scripts/ are in the check's paths scope.
- size-base and the package-size lookup share lib/main-artifact.cjs.
…ied-components table (name links docs, Storybook, size, LoC, Visual · A11y · RTL marks), collapsed visual thumbnails, package size lines, one not-run hint

Changed shots' frames are published to the PR's assets branch by
publish-frames.mjs so the report can show before/after/diff thumbnails;
the touched-components report (wide-change flag included) is read from its
own artifact.
…s/pr-<n>, and the daily pruner removes that branch once its PR closes
…rked .pr-report-frames); hand-pushed assets branches keep the existing rule
…move to their own PR); a failed visual run never shows a pass; an unmeasured base says so; shared sizeText; shorter not-run line
…ames-only job publishes to assets/pr-<n>; the pruner removes only branches it created once their PR closes
@vercel

vercel Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 31, 2026 11:21pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 31, 2026
@astracat-bot

astracat-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 11:32 PM UTC

@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · b7c3bb4

Storybook · Sandbox · built from this PR's head

Package size: no published package changed; no new dependencies.

Not run on this PR: add ci:run-visual, ci:run-a11y or ci:run-rtl to run them.


Generated by PR Enrichment workflow | View full report

@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 4557523

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested. The frames job and its trust checks look sound, but the stacked compact report needs these fixes, and this pull request should be rebased once #9171 merges so it carries only its own 830 lines.

  • The report no longer shows each changed package stylesheet's gzip size before and after, which spec:AST-080 FR8 requires; analyze-pr.js still measures it (stylesheetGzipBytes).
  • pr-status.js adds a second Before / After / Diff renderer beside visual-format.js, which is left with no caller; keep one of them.
  • The not-run line rebuilds the suite labels instead of using run-labels.cjs, and already lists a ci:run-perf label that does not exist.

[Automated review]

const baseStats = readOptionalJson('base-stats', baseStatsFile);
if (baseStats) {
analysis.baseComponentStats = baseStats.baseComponentStats || undefined;
analysis.baseBundlePackages = baseStats.baseBundlePackages || undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

baseBundlePackages is still read here, but nothing in lib/pr-status.js renders the package-stylesheet figure (bundlePackages[].stylesheetGzipBytes against baseBundlePackages). spec:AST-080 FR8 requires each changed package stylesheet's gzip size, base against head, beside the per-component CSS delta. The base report printed | @astryxdesign/core | 58.6KB | 59.6KB | +1000B | for a stylesheet growing 60,000 to 61,000 bytes; the head prints nothing. Could packagesLine add one stylesheet line per changed package?

if (!row.kinds.includes(kind)) return '—';
const file = `${kind}/${row.key}.png`;
if (!published.has(file)) return 'not published';
return `<img src="${base}${kind}/${encodeURIComponent(String(row.key))}.png" height="120" alt="${kind} frame">`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a second Before / After / Diff renderer: lib/visual-format.js buildVisualSection already builds these <img> cells from an image base (lines 83-113), and after this change nothing calls it except its own test. Please keep one: extend buildVisualSection for the compact layout and call it here, or delete visual-format.js and its test.

return false;
});
if (notRun.length === 0) return '';
const labels = notRun.map(suite => `\`ci:run-${suite.id}\``);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The labels and display names here copy run-labels.cjs (SUITE_LABELS, SUITE_NAMES, notRunSentence, which exist for this comment's not-run sentence). The copy has already drifted: SUITES adds perf, whose ci:run-perf label run-labels.cjs does not define. Could run-labels.cjs export the combined line, or its maps, for this function to call?

@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 077507f

@cixzhang
cixzhang marked this pull request as ready for review October 31, 2026 22:21

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested. The frames job and its trust checks look sound, but two duplicated code paths need one owner each before this lands.

  • framesToPublish in publish-frames.mjs re-implements the report's shot selection from buildVisualDeltas (the order and the 12-shot cut, held in three places); export one selector from pr-status.js and call it, so thumbnails cannot drift from the rows.
  • The new thumbnail cells duplicate the Before / After / Diff renderer in visual-format.js, which now has no caller; keep one of them.
  • The publish-frames.mjs header says it runs in the comment job; it runs in the frames job.

[Automated review]

Comment on lines +62 to +73
export function framesToPublish(verdict, limit) {
if (!verdict || verdict.status === 'skipped' || verdict.status === 'failed')
return [];
const changes = [...(verdict.changes || [])].sort(
(a, b) => Number(b.diffPixels ?? 0) - Number(a.diffPixels ?? 0),
);
const shots = [
...changes.map(change => ({key: change.key, kinds: KINDS})),
...(verdict.added || []).map(key => ({key, kinds: ['after']})),
...(verdict.removed || []).map(key => ({key, kinds: ['before']})),
].filter(shot => KEY.test(String(shot.key ?? '')));
return shots.slice(0, Math.min(Number(limit) || 0, MAX_SHOTS));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repeats the row choice in buildVisualDeltas (lib/pr-status.js:295-307: changes by diffPixels, then added, then removed, cut at VISUAL_FRAME_LIMIT). The thumbnails line up only while both copies agree, and the cut now lives in three places (VISUAL_FRAME_LIMIT, MAX_SHOTS, --limit 12). Could pr-status.js export one selector that this calls?

* already use) under `visual/<head sha>/`, marking a branch it creates with
* MARKER so only such branches are pruned, and writes `image_base` (their
* raw URL prefix) to GITHUB_OUTPUT. Writes nothing when no shot changed.
* @position The PR Comment workflow's comment job, after the visual report is

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@position says the comment job, but this runs in the frames job, the only one with contents: write. @output also leaves out the published output (line 219).

@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec b7c3bb4

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommending approval: an engineering owner's /approve-spec on this head settles the direction of the frame thumbnails, the write-scoped frames job and the pruner's marker rule, and the earlier findings (the copied shot selection, the second renderer and the @position header) are fixed. Three optional cleanups remain:

  • --limit 12 in pr-comment.yml restates VISUAL_FRAME_LIMIT; dropping it leaves one owner for the 12-shot cut.
  • In pr-status.js, the old buildVisualDeltas JSDoc now sits above visualShots, and the file header does not mention the thumbnails.
  • ci-tooling-routing.test.mjs checks that comment skips tooling-only runs but not frames; one more expect pins it.

[Automated review]

--pr "$PR_NUMBER" \
--head-sha "$HEAD_SHA" \
--repository "$GITHUB_REPOSITORY" \
--limit 12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this restates VISUAL_FRAME_LIMIT (pr-status.js:46). framesToPublish already clamps to it, so dropping --limit here (and the ?? 12 default in publish-frames.mjs) leaves one owner for the cut; today the two agree.

* without it, each row names its change.
*/
function buildVisualDeltas({verdict}) {
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: the JSDoc just above (lines 285-291) describes buildVisualDeltas but now sits orphaned above visualShots's own, so buildVisualDeltas (line 314) has none. Moving it down, and adding the published frames and thumbnails to the file header's @input / @output, keeps the docs accurate.

@cixzhang
cixzhang merged commit 2218dee into main Oct 31, 2026
52 checks passed
@github-actions
github-actions Bot deleted the ci/pr-report-frames branch November 1, 2026 06:05

This branch was successfully deployed

1 active deployment
Preview — b7c3bb46 Deployed Oct 31, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant