Private UI previews on pull requests
Two independent, informational pipelines build the PR’s exact base and head commits, not GitHub’s synthetic merge commit:
Storybook interactive: build → encrypt → publish interactive StorybooksStorybook capture: build → screenshots/videos → encrypt → publish comparisonsThe duplication is intentional: it keeps the encrypted handoff and secret-free build jobs simple. Interactive publication never waits for capture or video success. Scheduling and build time can still affect which finishes first. Do not make these informational workflows required branch-protection checks.
What appears on a PR
There are two independently updated bot comments, not one combined message:
| Comment | Protected deployment |
|---|---|
| Interactive Storybook | https://pr-<number>-interactive.persyk-ui-previews.pages.dev/ |
| UI comparison | https://pr-<number>-comparison.persyk-ui-previews.pages.dev/ |
The interactive landing page links to the full Before and After Storybooks. The comparison report includes changed/added/removed screenshots, pixel diffs, and selected videos, plus its own copies of the interactive builds. The two targets cannot overwrite one another. A capture failure updates only the comparison comment; an available interactive preview remains linked.
Each comment reports publishing, ready, failed/cancelled, or bootstrap status and links to its source/publishing runs and exact commits. Failed source workflows also trigger the trusted status reporter. Missing, expired, or invalid artifacts and deployment failures get fixed, actionable messages rather than disappearing silently. Credential/project access is checked before downloading private artifacts. Failures before provenance verification cannot safely be attributed to a PR and appear only in Actions. Stale, closed, forked, spoofed, and superseded runs do not update comments or publish artifacts.
The public comments contain controlled status text, links and commit IDs only: no screenshots, video embeds, credentials, arbitrary CLI output, API response bodies, private identities, or login URLs. Sign in through Cloudflare Access using the configured owner’s email. The root Pages site remains an innocuous access check. Never publish previews to production or an unprotected custom domain.
Selection and capture
capture:screenshot on a story/meta opts into light and dark screenshots.
!capture:screenshot excludes interactive stories from still capture. The union
of both story indices is used, so new/removed stories work and the first tagged
version can compare against its untagged base. Retiring a capture tag without
removing the story keeps one final comparison in the union.
Story discovery is dynamic, not a hard-coded count. The browser suite renders all
type: story entries in /index.json, rejects an empty story index, and requires
the explicitly registered capture flows to exist.
capture:video marks scripted flows in
scripts/visual-preview/capture-flows.mjs: session-panel simulation
(windows-sessionpanel--simulation) and message-removal failure/retry
(windows-sessionpanel--deletion-fails-and-retry-succeeds). Screenshots target
[data-testid="session-window"] for session views. Both the capture CLI and browser tests execute this registry’s
same functions. Add new scripted flows there and tag their stories; a tag alone
cannot invent interactions. Changes to related screenshots trigger videos.
Applying preview:video forces videos, including motion-only changes. Label
updates rerun the comparison pipeline, not the interactive pipeline.
Captures use fixed Chromium/viewport/scale, locale, timezone and reduced motion. Still capture disables CSS animation and waits for fonts and two matching images. Videos keep motion enabled and provide deliberate viewing time; operation completion uses assertions and mock controls. External browser requests are blocked. No microphone, provider, real recording or persisted user settings are used. Browsers do not verify native macOS vibrancy, resizing/dragging or WKWebView fidelity.
Local use
pnpm build-storybook# Put two static builds at visual-preview/before and visual-preview/after.# Interactive preview: no browser, screenshot tags, or capture needed.node scripts/visual-preview/interactive.mjs visual-preview# Comparison report (at least one version needs capture tags):pnpm capture:storybookPREVIEW_VIDEO=true pnpm capture:storybook # force scripted videospython3 -m http.server 8080 --directory visual-preview --bind 127.0.0.1pnpm test:visual-previewpnpm test:storybookRun the landing generators in separate directories to keep both local variants;
otherwise the later one replaces the local landing page and manifest.
PREVIEW_PR, PREVIEW_BASE, and PREVIEW_HEAD label local output (default local).
Do not upload local output to public hosts. visual-preview/ is Git-ignored.
Image/landing, bundle, provenance, independent-status, and security regressions
run in pnpm test. Encryption roundtrip/tamper coverage additionally requires the
standard age and age-keygen CLIs.
CI trust boundary
storybook-interactive.ymlandstorybook-capture.ymlrun on same-repository PRs touching UI/tooling paths. Forks are skipped. Both use read-only permissions and no secrets. Application builds are untrusted. Packaging/capture tooling comes from the exact base commit, so tooling changes take effect after merging.- Each uploads only an age-encrypted archive, retained for one day. Public Actions artifacts are not private storage. The public age recipient is a repository variable, not a decryption credential. No plaintext preview, screenshot, video, report or trace is uploaded or cached.
- The shared
storybook-publish.ymlis triggered on completion of either source workflow, including failures. It executes only trusted scripts frommaster. Successful publication requires the allowlisted workflow path/ID, same repo, PR event, source success, exact current base/head, open PR, and correct pipeline artifact identity. Failed runs may report status but cannot deploy. Newer same-revision runs supersede older statuses. Encryption is confidentiality, not proof of provenance. - Bundles must match the PR commits and pipeline kind. Validation bounds sizes
(200 MiB total, 25 MiB/file), file count (10,000), and decompression. Traversal,
symlinks, duplicate/special files are rejected. Workers, Functions, redirects,
routing and custom headers are stripped; trusted headers are generated. No
artifact code or dependency is executed by the publisher. Legacy comparison
bundles without
kindremain comparison-only, never interactive. - Root and wildcard probe Access checks happen before upload; hash/branch/deep URLs are checked afterward. Failed verification deletes the deployment. Provenance is checked again before comment updates. Publication and cleanup remain serialized, with cancellation disabled; source workflows cancel older work independently per pipeline/PR.
The storybook-preview-publish GitHub environment permits only master. Its
secrets are STORYBOOK_AGE_IDENTITY and CLOUDFLARE_PAGES_API_TOKEN; its account ID
is a variable. Never move secrets to repository scope, broaden environment refs,
execute PR code through pull_request_target, or weaken Access protection.
Token rotation and recovery
Replace the deployment token
-
In the correct Cloudflare account, open Manage Account → API Tokens → Create Token → Create Custom Token.
-
Grant Account → Cloudflare Pages → Edit, restricted to the configured account where supported. Account Custom Pages is unrelated. Access-policy management and a Global API Key are not needed for publishing. GitHub-hosted runner IPs vary; do not add an incompatible IP restriction.
-
Update Settings → Environments → storybook-preview-publish → CLOUDFLARE_PAGES_API_TOKEN in GitHub. Paste only the token, without quotes or a
Bearerprefix. Never paste it into a PR, chat, or command argument. -
Run the trusted read-only check:
Terminal window gh workflow run storybook-publish.yml --ref master -f operation=diagnose -
Confirm Project access succeeded, then retry publication as below. Revoke the replaced token after the new token is verified; revoke immediately if the old token was exposed. Do not widen permissions to troubleshoot an invalid token.
Which run should I retry?
| Failure | Recovery |
|---|---|
| Build/capture | Inspect the source run; reproduce locally; fix and push, or rerun that source workflow. The other pipeline is independent. |
| Authentication | Rotate the Pages token and pass diagnose first. |
| Artifact expired/missing | Rerun the source workflow; ciphertext expires after one day. |
| Decryption after age rotation | Replace the identity/recipient pair consistently and rerun the source workflow; old ciphertext will not work. |
| Upload/network | Rerun the failed publisher if its artifact is fresh and commits are still current. |
| Access protection | Fix the root/wildcard/deployment coverage; never bypass checks or expose plaintext. |
| Stale/superseded run | Use the newest source run; if master changed, update the PR branch and trigger fresh source runs. |
| No comment | Check both source workflows and the publisher. Events excluded by path filters and failures before trusted PR verification cannot post a status. |
To retry a failed publisher: gh run rerun <publish-run-id> --failed. To regenerate
an artifact: gh run rerun <source-run-id>. Each regenerated bundle must still
match current PR provenance. No manual dispatch deploys arbitrary local files.
Rotate encryption keys
Stop publishing, replace the environment’s private STORYBOOK_AGE_IDENTITY and
repository’s public STORYBOOK_AGE_RECIPIENT together, then regenerate both source
bundles. Old artifacts cannot be decrypted with the new key. Never log or commit
private keys.
Provisioning, rollout and cleanup
scripts/visual-preview/setup.py is an explicit local setup utility. Credentials
are supplied securely in the process environment, never command arguments:
CLOUDFLARE_ACCOUNT_IDCLOUDFLARE_API_TOKEN: setup-only Pages Edit + Access Apps/Policies EditCLOUDFLARE_PAGES_API_TOKEN: separate Pages Edit-only token for CISTORYBOOK_ACCESS_EMAIL: exact allowed viewer email
Authenticate gh and install age. Run python3 scripts/visual-preview/setup.py <mode> for cloudflare, github, then probe. Probe uploads only innocuous
content and checks Access redirects; confirm human login too. No billing or
identity-provider changes are made. Revoke the broader setup token afterward;
never copy it into GitHub.
Both pipeline targets fit the existing wildcard Access application for
*.persyk-ui-previews.pages.dev; the exact root is protected separately. No new
Cloudflare project, domain, permission, or decryption key is required.
The publisher and new interactive tooling must land on master first. The initial
PR’s interactive workflow explicitly skips publication when its trusted base
lacks interactive.mjs. Once the publisher lands, it reports this as bootstrap,
not a successful deployment. New source workflow IDs must be available on master.
Existing capture continues to use old trusted tooling during rollout. After
merging, refresh a test PR against master and confirm both comments/URLs,
then exercise an intentional capture failure to confirm the interactive comment
and deployment remain intact. Do not bypass the boundary to test PR-owned
publishing code with secrets.
The daily cleanup job (also dispatchable with operation=cleanup) removes both
pipeline deployments, plus legacy pr-<number> deployments, after 14 days or PR
closure. It leaves production/probe deployments alone. The old combined bot comment
is reused for comparisons, avoiding a third status comment on existing PRs;
legacy deployment links expire naturally.
References: Pages CI upload, preview Access protection, root domain protection, age.