Skip to content

trunk-merge/pr-110452/e2121c63-277d-4dc6-be10-e7b1ae327a8a #353194

trunk-merge/pr-110452/e2121c63-277d-4dc6-be10-e7b1ae327a8a

trunk-merge/pr-110452/e2121c63-277d-4dc6-be10-e7b1ae327a8a #353194

Workflow file for this run

name: Storybook
on:
pull_request:
# PRs outside the merge queue run a narrowed visual-regression matrix when the
# Storybook module graph can safely identify affected stories. Merge-queue runs
# use the full matrix. To force the full matrix earlier, add the `run-ci-frontend`
# label. Force it also to give a story back a baseline entry it lost: the narrowed
# matrix renders only the stories the diff affects, so a story the diff does not
# touch is never captured, and a reviewer has nothing to approve into
# `frontend/snapshots.yml`. The label widens a run that happens anyway: a PR that
# changes no path the `changes` job watches builds no Storybook, label or not.
# The `no-ci` label does the opposite: it silences this workflow on a draft
# entirely (prototypes).
# No labeled/unlabeled triggers: GitHub cannot filter a label trigger by name,
# so every unrelated label re-ran the full chromium matrix against a commit CI
# had already covered. A label here takes effect from the next push, or from
# ready_for_review.
types: [opened, synchronize, reopened, ready_for_review]
push:
branches:
- master
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
env:
RUNS_ON_INTERNAL_PR: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
# npm's audit blocks the pnpm bootstrap. See .github/actions/pnpm-install for why.
NPM_CONFIG_AUDIT: 'false'
NPM_CONFIG_FUND: 'false'
jobs:
# Learning mode: nothing reads these outputs, so a recommendation cannot change what runs.
# Fork and Dependabot runs get no secret; the queue always runs everything anyway.
# The step fails open, because this telemetry must not turn a passing workflow red.
dynamic-ci-filter:
name: Trunk Dynamic CI (Learning Mode)
if: >-
github.event_name == 'pull_request' &&
github.repository == 'PostHog/posthog' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]' &&
!startsWith(github.head_ref, 'trunk-merge/') &&
(github.event.pull_request.draft != true ||
!contains(github.event.pull_request.labels.*.name, 'no-ci'))
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions: {}
steps:
- name: Ask Trunk which jobs this diff needs
continue-on-error: true
uses: trunk-io/dynamic-ci@7e3af9331e8ebdfe0c71ba7d0ff6b7424dde8c57 # v1
with:
token: ${{ secrets.TRUNK_API_TOKEN }}
# Job to decide if we should run storybook ci
# See .github/actions/paths-filter/README.md for filter semantics
changes:
runs-on: ubuntu-24.04
timeout-minutes: 5
# A draft PR labeled `no-ci` skips this workflow entirely (prototypes):
# every other job needs this one, and the gates treat skipped as success.
# ready_for_review re-runs everything, so the merge gate is unaffected.
if: >-
github.event_name != 'pull_request'
|| github.event.pull_request.draft != true
|| startsWith(github.head_ref, 'trunk-merge/')
|| !contains(github.event.pull_request.labels.*.name, 'no-ci')
name: Determine need to run storybook checks
# Set job outputs to values from filter step
outputs:
# Two positive filters compared by count exclude generated-only PRs.
# This predates the vendored paths-filter's include-exclude matching;
# it could now be a plain '!frontend/src/generated/**' exclude on the
# frontend filter, kept as-is here to leave gating behavior untouched.
#
# Additionally skip when a bot's push adds only snapshot baselines —
# Visual Review auto-commits frontend/snapshots.yml after human review
# and CI approval, so re-running the suite is pure waste. The detect
# step keys off the push delta (this push's commits), not the whole-PR
# diff the filter reports, so it fires even when the PR also has code changes.
frontend: >-
${{
github.event_name == 'push'
|| (steps.filter.outputs.frontend == 'true'
&& steps.exclude.outputs.outside_node_product_workspaces == 'true'
&& fromJSON(steps.filter.outputs.frontend_count || '0')
> fromJSON(steps.filter.outputs.frontend_generated_count || '0')
&& steps.baseline_push.outputs.result != 'true')
}}
matrix: ${{ steps.matrix.outputs.matrix }}
# Trunk's merge queue opens its PR from trunk-merge/**. Scoped to same-repo
# heads because github.head_ref is author-controlled: without the scope, any
# contributor could opt out of the visual gates below by naming their branch
# trunk-merge/**. Only write access can push such a branch here.
merge_queue: >-
${{
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
&& startsWith(github.head_ref, 'trunk-merge/')
}}
steps:
# Needed by the "Derive Node-only product workspace exclusions" step (reads
# pnpm-workspace.yaml + bin/frontend-exclude-filter) and by the local
# ./.github/actions/paths-filter steps, which must exist on disk (on
# pull_request events they still diff via the API, not the checkout).
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
.github/actions/paths-filter
bin/frontend-exclude-filter
pnpm-workspace.yaml
sparse-checkout-cone-mode: false
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
id: app-token
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
with:
client-id: ${{ vars.GH_APP_POSTHOG_PATHS_FILTER_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_PATHS_FILTER_PRIVATE_KEY }}
- uses: ./.github/actions/paths-filter
id: filter
if: github.event_name != 'push' # Run all tests on master push
with:
token: ${{ steps.app-token.outputs.token || github.token }}
filters: |
frontend:
- 'patches/jest-junit@16.0.0.patch'
- 'frontend/**'
- 'products/**/*.{ts,tsx}'
- 'products/**/frontend/**'
# products/desktop is the imported desktop app; it has its own
# desktop-* CI and must not drag this suite onto its PRs
- '!products/desktop/**'
- 'common/{esbuilder,mosaic,storybook,tailwind}/**'
# quill is consumed app-wide via `workspace:*`, so a change here
# alters rendering across the main-app stories — run the suite.
- 'packages/quill/**'
- 'ee/frontend/**'
- 'services/mcp/src/ui-apps/**'
- '.storybook/**'
- 'package.json'
- '.github/workflows/ci-storybook.yml'
- 'playwright.config.ts'
frontend_generated:
- 'frontend/src/generated/**'
- 'products/**/frontend/generated/**'
- name: Detect Visual Review baseline-only push
id: baseline_push
if: github.event_name == 'pull_request' && github.event.action == 'synchronize'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.event.after }}
run: |
set -euo pipefail
result=false
# Visual Review commits the approved baseline as a bot (a [bot] app or posthog-bot).
case "$ACTOR" in
*'[bot]' | posthog-bot) is_bot=true ;;
*) is_bot=false ;;
esac
zero=0000000000000000000000000000000000000000
if [ "$is_bot" = true ] && [ -n "$BEFORE" ] && [ "$BEFORE" != "$zero" ] && [ -n "$AFTER" ]; then
# compare uses merge-base(before, after), which for an appended commit is
# exactly the push delta; a force-push/rebase widens it and won't match.
files=$(gh api "repos/${REPO}/compare/${BEFORE}...${AFTER}" --jq '.files[].filename' || true)
if [ -n "$files" ] && ! grep -qvE '^(frontend|playwright)/snapshots\.yml$' <<<"$files"; then
result=true
fi
fi
echo "result=$result" >>"$GITHUB_OUTPUT"
echo "Visual Review baseline-only push: $result"
# The broad `products/**/*.{ts,tsx}` rule above matches all product code,
# including product dirs that are their own Node services
# (products/<product>/services/**) — no stories, nothing in the bundle.
# Exclude those so a PR touching only them skips the visual-regression suite.
# The excluded dirs are derived from pnpm-workspace.yaml (the product paths that
# declare nested pnpm workspaces), not hardcoded — see bin/frontend-exclude-filter.
#
# A separate step is required because these exclusions are generated at
# runtime from pnpm-workspace.yaml into a filter file, while the main filter
# above is inline YAML — paths-filter takes one or the other. The exclude-only
# filter is true only when some changed file lives OUTSIDE every excluded dir.
- name: Derive Node-only product workspace exclusions
if: github.event_name != 'push'
run: bin/frontend-exclude-filter > "$RUNNER_TEMP/frontend-exclude.yml"
- uses: ./.github/actions/paths-filter
id: exclude
if: github.event_name != 'push'
with:
token: ${{ steps.app-token.outputs.token || github.token }}
filters: ${{ runner.temp }}/frontend-exclude.yml
# Build the visual-regression matrix.
# WebKit runs wherever a break can still be stopped: on master pushes, and on the
# merge-queue run that gates the merge. No story opts into webkit snapshots (see
# common/storybook/.storybook/test-runner.ts), so webkit adds render-error coverage
# only. That coverage is worth a batched queue run, because a webkit-only break then
# fails the queue instead of master. It is not worth every push of every frontend PR,
# so author PRs stay chromium-only.
- name: Build matrix
id: matrix
env:
MERGE_QUEUE: >-
${{
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
&& startsWith(github.head_ref, 'trunk-merge/')
}}
run: |
# Shard counts — change here to resize the matrix.
CHROMIUM_SHARDS=16
WEBKIT_SHARDS=4
shards() {
jq -cn --arg browser "$1" --argjson count "$2" \
'[range(1; $count + 1) | {browser: $browser, shard_count: $count, shard: .}]'
}
CHROMIUM=$(shards chromium "$CHROMIUM_SHARDS")
if [ "${{ github.event_name }}" = "pull_request" ] && [ "$MERGE_QUEUE" != "true" ]; then
MATRIX=$(jq -cn --argjson c "$CHROMIUM" '{include: $c}')
else
WEBKIT=$(shards webkit "$WEBKIT_SHARDS")
MATRIX=$(jq -cn --argjson c "$CHROMIUM" --argjson w "$WEBKIT" '{include: ($c + $w)}')
fi
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
build-storybook:
name: Build Storybook (depot-ubuntu-24.04)
runs-on: depot-ubuntu-24.04
# The Vite build normally takes ~3.5 minutes, but a degraded runner can stretch
# the memory-heavy bundling phase past 15 minutes — leave headroom so a slow
# runner yields a slow-but-green build instead of a cancelled one that hard-fails
# the visual-regression gate.
timeout-minutes: 30
needs: changes
if: needs.changes.outputs.frontend == 'true'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Install pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Fix node-gyp permissions
run: find ~/setup-pnpm -name gyp_main.py -exec chmod +x {} +
- name: Mint setup-action GitHub token
id: setup-gh-token
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.GH_APP_POSTHOG_SETUP_ACTIONS_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_SETUP_ACTIONS_PRIVATE_KEY }}
skip-token-revoke: true
# This job checks out the PR head (not the merge ref), so it can't use
# the ./.github/actions/pnpm-install local action — an un-rebased head
# may not contain it. Restore the store inline instead, sharing the same
# pnpm-<os>-<lockhash> key as the action.
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: .nvmrc
token: ${{ steps.setup-gh-token.outputs.token || github.token }}
- name: Get pnpm store path
id: pnpm-store
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm cache
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies
run: pnpm --filter=@posthog/storybook... install --frozen-lockfile
# Vite's dependency optimization cache (node_modules/.vite) is the most
# expensive part of the build — without it, Vite re-bundles every module
# from scratch, which dwarfs the rest of the build. The
# pnpm lockfile hash ensures the cache is invalidated when deps change.
- name: Restore Vite build cache
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: common/storybook/node_modules/.vite
key: storybook-vite-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: storybook-vite-${{ runner.os }}-
- name: Build Storybook
env:
NODE_OPTIONS: --max-old-space-size=32768
run: |
bin/turbo --filter=@posthog/storybook prepare
pnpm --filter=@posthog/storybook build --test
- name: Save Vite build cache
if: github.ref == 'refs/heads/master'
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: common/storybook/node_modules/.vite
key: storybook-vite-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Upload Storybook build artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: storybook-build
path: common/storybook/dist
retention-days: 1
# Consumed by the select-stories job below. Uploaded separately so
# the visual-regression matrix doesn't pay to download it on every
# shard.
# module-graph.json only exists once the Vite build completes, so no
# `if: always()`: on a failed/cancelled build it would just warn.
# select-stories falls back to the full matrix when the graph is
# missing, so a failed upload costs one narrowing opportunity rather
# than the build and the whole visual-regression matrix behind it.
- name: Upload module-graph artifact
continue-on-error: true
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: storybook-module-graph
path: common/storybook/dist/module-graph.json
retention-days: 1
if-no-files-found: warn
vr-setup:
name: Create Visual Review run
runs-on: ubuntu-24.04
# The VR CLI build dominates this job whenever the cache misses, and it then
# downloads the whole dependency tree. vr-complete allows the same budget for the
# same build.
timeout-minutes: 15
needs: [changes, build-storybook, select-stories]
# A status function is required because select-stories is skipped on forced-full
# PRs and master pushes, and GitHub would otherwise cascade that skip to this job.
# !cancelled() rather than always() so a superseded run stops here instead of
# creating a Visual Review run that its cancelled matrix will never complete.
if: |
!cancelled()
&& needs.changes.outputs.frontend == 'true'
&& needs.build-storybook.result == 'success'
&& needs.select-stories.outputs.should_run != 'false'
&& (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push')
outputs:
run_id: ${{ steps.create.outputs.run_id }}
steps:
# Head checkout supplies the PR's snapshot baseline; the VR CLI itself is
# built from the base branch below.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
sparse-checkout: |
.nvmrc
frontend/snapshots.yml
sparse-checkout-cone-mode: false
# Build the VR CLI from the base branch, not the PR head. A workflow edit
# reaches every open PR immediately (it runs PR-merged-with-base), but a CLI
# change only lands on a branch once it rebases — so building from head can
# feed an old binary flags this workflow added (e.g. --partial), failing the
# run. Pinning to base.ref keeps the CLI in lockstep with the workflow. The
# tsconfig rootDir is the parent dir, so the generated types must come along.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.ref || github.sha }}
path: vr-cli
sparse-checkout: |
products/visual_review/cli
products/visual_review/frontend/generated/api.schemas.ts
sparse-checkout-cone-mode: false
- name: Mint setup-action GitHub token
id: setup-gh-token
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.GH_APP_POSTHOG_SETUP_ACTIONS_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_SETUP_ACTIONS_PRIVATE_KEY }}
skip-token-revoke: true
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: .nvmrc
token: ${{ steps.setup-gh-token.outputs.token || github.token }}
# The CLI is a standalone npm package outside the pnpm workspace, so no other
# job warms it. Cache the built output, not the tarballs: a hit runs no npm
# command, and the install is the part that fails on a registry blip.
# The key covers the CLI source, the generated types it compiles, the Node
# version, and the architecture, because sharp ships one binary per architecture.
# No restore-keys: a prefix match would hand the job a build of other source.
- name: Restore VR CLI build
id: restore-vr-cli
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
vr-cli/products/visual_review/cli/dist
vr-cli/products/visual_review/cli/node_modules
key: vr-cli-build-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', 'vr-cli/products/visual_review/cli/**', 'vr-cli/products/visual_review/frontend/generated/api.schemas.ts') }}
# Runs only on a miss. A reset during a tarball read is a plain stream error
# that npm does not retry, so retry the install once. The random wait spreads
# the jobs that all miss on the same CLI change. fetch_retries=0 stops npm from
# stacking its own attempts under ours, and the fetch timeout bounds a stalled
# registry. The prune drops the build toolchain from the cached tree.
- name: Build VR CLI
if: steps.restore-vr-cli.outputs.cache-hit != 'true'
timeout-minutes: 8
shell: bash
working-directory: vr-cli/products/visual_review/cli
env:
npm_config_fetch_retries: 0
npm_config_fetch_timeout: 60000
run: |
npm ci --no-audit --no-fund || { echo "::warning::VR CLI install failed, retrying once"; sleep $((5 + RANDOM % 26)); npm ci --no-audit --no-fund; }
npm run build
npm prune --omit=dev
- name: Save VR CLI build
# Master only: the key is immutable once written, so a PR must never take it.
# The cache-hit check skips packing a tree that already has an entry, because
# actions/cache builds the archive before it reserves the key. The shards save
# their own version of this entry, see there.
if: github.ref == 'refs/heads/master' && steps.restore-vr-cli.outputs.cache-hit != 'true'
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
vr-cli/products/visual_review/cli/dist
vr-cli/products/visual_review/cli/node_modules
key: ${{ steps.restore-vr-cli.outputs.cache-primary-key }}
- name: Create VR run
id: create
env:
VR_TOKEN: ${{ secrets.VR_API_TOKEN }}
VR_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }}
VR_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
VR_PR: ${{ github.event.pull_request.number }}
# PRs are gating ("review"); master pushes are tracking-only ("observe") since
# there's no PR to approve and we don't want master runs to block or prompt for approval.
# trunk-merge/** is observe for the same reason: the queue's PR is ephemeral, so a
# diff there has no reviewer and no approval path. Master baseline drift would
# otherwise block every PR behind it on a diff none of their authors can resolve.
# The run is still created, so merge-time drift stays visible in Visual Review.
VR_PURPOSE: ${{ (github.event_name == 'push' || needs.changes.outputs.merge_queue == 'true') && 'observe' || 'review' }}
# Selective storybook runs only render a subset of stories. Without --partial,
# VR would flag every untouched baseline identifier as removed.
VR_PARTIAL: ${{ needs.select-stories.outputs.mode == 'selective' && '--partial' || '' }}
# The rerun target for recompute until the complete job records its own ID.
JOB_CHECK_RUN_ID: ${{ job.check_run_id }}
run: |
# shellcheck disable=SC2086
RUN_ID=$(node vr-cli/products/visual_review/cli/dist/cli/src/index.js run create \
--type storybook \
--baseline frontend/snapshots.yml \
--branch "$VR_BRANCH" \
--commit "$VR_COMMIT" \
--pr "$VR_PR" \
--purpose "$VR_PURPOSE" \
--token "$VR_TOKEN" \
$VR_PARTIAL)
echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT
visual-regression:
name: Visual regression tests - ${{ matrix.browser }} (${{ matrix.shard }}/${{ matrix.shard_count }})
runs-on: ${{ matrix.browser == 'webkit' && 'depot-ubuntu-24.04' || 'ubuntu-24.04' }}
needs: [changes, build-storybook, vr-setup, select-stories]
# A status function is required because select-stories is skipped on forced-full
# PRs and master pushes. !cancelled() rather than always() so a superseded run's
# browser shards stop promptly instead of running the full 30-minute matrix.
if: >-
!cancelled()
&& needs.changes.outputs.frontend == 'true'
&& needs.build-storybook.result == 'success'
&& needs.select-stories.outputs.should_run != 'false'
# Healthy shards finish well under 15 minutes; the cap exists so a wedged shard fails fast.
timeout-minutes: 30
container:
image: ghcr.io/posthog/playwright:v1.60.0@sha256:83192064c7510f7ee73dd63dc5f22a5e01a92c81a2e6a9c715d9e3fe55471fd9
strategy:
fail-fast: false
# Shard counts and worker config (maxWorkers in common/storybook/package.json)
# are tuned for runner CPU — consult #team-devex before changing.
# PRs may use chromium shards running only affected story files.
# Forced-full PRs fall back to changes.outputs.matrix (full chromium),
# while master pushes and merge-queue runs add webkit.
matrix: ${{ fromJson(needs.select-stories.outputs.matrix || needs.changes.outputs.matrix) }}
env:
NODE_OPTIONS: --max-old-space-size=16384
OPT_OUT_CAPTURE: 1
JEST_JUNIT_SUITE_NAME: '{filepath}'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Install pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Fix node-gyp permissions
run: find ~/setup-pnpm -name gyp_main.py -exec chmod +x {} +
- name: Mint setup-action GitHub token
id: setup-gh-token
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.GH_APP_POSTHOG_SETUP_ACTIONS_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_SETUP_ACTIONS_PRIVATE_KEY }}
skip-token-revoke: true
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: .nvmrc
token: ${{ steps.setup-gh-token.outputs.token || github.token }}
- name: Get pnpm store path
id: pnpm-store
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
id: pnpm-restore
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install package.json dependencies with pnpm
run: pnpm --filter=@posthog/storybook... install --frozen-lockfile
# Inside the playwright container pnpm keeps its store on the workspace mount.
# actions/cache keys an entry on key plus path, so this is a separate entry
# from the one pnpm-store-cache.yml writes, and one shard saves it.
- name: Save pnpm cache
if: github.ref == 'refs/heads/master' && matrix.shard == 1 && matrix.browser == 'chromium' && steps.pnpm-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Download Storybook build artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: storybook-build
path: common/storybook/dist
- name: Serve Storybook in the background
# http-server and wait-on are devDependencies of @posthog/storybook.
# Run via `pnpm --filter` so pnpm resolves them from common/storybook/node_modules/.bin
# (no global install) and cd's into the package — `dist` is relative to common/storybook.
run: |
retries=5
max_timeout=30
pnpm --filter=@posthog/storybook exec http-server dist --port 6006 --silent &
server_pid=$!
echo "Started http-server with PID: $server_pid"
# Give the server a moment to start
sleep 2
while [ $retries -gt 0 ]; do
echo "Checking if Storybook is available (retries left: $retries, timeout: ${max_timeout}s)..."
if pnpm --filter=@posthog/storybook exec wait-on http://127.0.0.1:6006 --timeout $max_timeout; then
echo "✅ Storybook is available at http://127.0.0.1:6006"
break
fi
retries=$((retries-1))
if [ $retries -gt 0 ]; then
echo "⚠️ Failed to connect to Storybook, retrying... ($retries retries left)"
# Check if server is still running
if ! kill -0 $server_pid 2>/dev/null; then
echo "❌ http-server process is no longer running, restarting it..."
pnpm --filter=@posthog/storybook exec http-server dist --port 6006 --silent &
server_pid=$!
echo "Restarted http-server with PID: $server_pid"
sleep 2
fi
fi
done
if [ $retries -eq 0 ]; then
echo "❌ Failed to serve Storybook after all retries"
# Try to get some diagnostic information
echo "Checking port 6006 status:"
netstat -tuln | grep 6006 || echo "Port 6006 is not in use"
echo "Checking http-server process:"
ps aux | grep http-server || echo "No http-server process found"
echo "Checking Storybook dist directory:"
ls -la common/storybook/dist || echo "Storybook dist directory not found"
exit 1
fi
# Wipe committed PNGs so each shard only has freshly captured screenshots.
# Prevents stale hashes from winning the race in VR's get_or_create.
- name: Clean snapshot directory
run: find frontend/__snapshots__ -name '*.png' -delete 2>/dev/null || true
# Capture-only: VR is the gate for visual changes, jest just captures screenshots
- name: Run @storybook/test-runner
id: test-runner
# continue-on-error so the quarantine gate below is the verdict — it passes when
# every failure is an already-quarantined flake, fails otherwise.
continue-on-error: true
shell: bash
env:
HOME: /root
STORYBOOK_SKIP_TAGS: 'test-skip,test-skip-${{ matrix.browser }}'
MODE: ${{ needs.select-stories.outputs.mode }}
AFFECTED_FILES_JSON: ${{ needs.select-stories.outputs.affected_files_json }}
run: |
if [ "$MODE" = "selective" ]; then
# test-storybook is jest-based; positional path args filter the run to
# only those story files before Jest distributes them across shards.
affected_files_path=$(mktemp)
node -e 'for (const file of JSON.parse(process.env.AFFECTED_FILES_JSON)) process.stdout.write(`${file}\0`)' > "$affected_files_path"
mapfile -d '' -t AFFECTED_FILES < "$affected_files_path"
rm "$affected_files_path"
if [ "${#AFFECTED_FILES[@]}" -eq 0 ]; then
echo "::error::Selective mode did not provide affected story files"
exit 1
fi
pnpm --filter=@posthog/storybook test:visual:ci:update --browsers ${{ matrix.browser }} --shard ${{ matrix.shard }}/${{ matrix.shard_count }} -- "${AFFECTED_FILES[@]}"
else
pnpm --filter=@posthog/storybook test:visual:ci:update --browsers ${{ matrix.browser }} --shard ${{ matrix.shard }}/${{ matrix.shard_count }}
fi
# Upload snapshots to Visual Review (runs in parallel across shards), or verify
# them offline on a fork PR. Fork PRs get no VR_API_TOKEN, so vr-setup skips and
# leaves run_id empty. The fork arm reads the event, not env, because the fork's
# test code runs earlier in this job and can write to $GITHUB_ENV. Build the CLI
# from the base branch (see vr-setup for the rationale), which also keeps fork
# code out of the CLI.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
if: always() && (needs.vr-setup.outputs.run_id != '' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository))
with:
ref: ${{ github.event.pull_request.base.ref || github.sha }}
path: vr-cli
sparse-checkout: |
products/visual_review/cli
products/visual_review/frontend/generated/api.schemas.ts
sparse-checkout-cone-mode: false
# Same key as vr-setup. The entry vr-setup writes is invisible here: this
# container has no zstd, and actions/cache folds the compression method into
# the cache version. The save below writes the container version.
- name: Restore VR CLI build
id: restore-vr-cli
if: always() && (needs.vr-setup.outputs.run_id != '' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository))
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
vr-cli/products/visual_review/cli/dist
vr-cli/products/visual_review/cli/node_modules
key: vr-cli-build-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', 'vr-cli/products/visual_review/cli/**', 'vr-cli/products/visual_review/frontend/generated/api.schemas.ts') }}
# Runs only on a miss, and retries the install for the reason given at vr-setup.
- name: Build VR CLI
id: build-vr-cli
if: always() && (needs.vr-setup.outputs.run_id != '' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) && steps.restore-vr-cli.outputs.cache-hit != 'true'
timeout-minutes: 8
shell: bash
working-directory: vr-cli/products/visual_review/cli
env:
npm_config_fetch_retries: 0
npm_config_fetch_timeout: 60000
run: |
npm ci --no-audit --no-fund || { echo "::warning::VR CLI install failed, retrying once"; sleep $((5 + RANDOM % 26)); npm ci --no-audit --no-fund; }
npm run build
npm prune --omit=dev
# always() keeps the upload after a test-runner failure. A failed build drops
# it. A skipped build (cache hit) is not a failure.
- name: Upload snapshots to Visual Review
if: always() && needs.vr-setup.outputs.run_id != '' && steps.build-vr-cli.outcome != 'failure'
env:
VR_TOKEN: ${{ secrets.VR_API_TOKEN }}
run: |
VR_CLI=vr-cli/products/visual_review/cli/dist/cli/src/index.js
# This container step runs under sh, so the optional flags go through `set --`
# rather than a bash array.
set -- \
--run-id "${{ needs.vr-setup.outputs.run_id }}" \
--dir frontend/__snapshots__/ \
--baseline frontend/snapshots.yml \
--token "$VR_TOKEN"
# The story index lets Visual Review name each snapshot's owning team. The CLI is
# built from the base ref, so a merge-queue run can pair this workflow with a CLI
# that predates the flag. Pass it only when the CLI knows it.
if node "$VR_CLI" run upload --help | grep -q -- '--storybook-index'; then
set -- "$@" --storybook-index common/storybook/dist/index.json --storybook-root common/storybook
fi
node "$VR_CLI" run upload "$@"
# Without this step a fork PR's gate reads the skipped Visual Review jobs as a pass,
# and the merge queue is the first run to compare its screenshots. verify compares
# exact pixel hashes with no API call. It does not know the tolerated alternate hashes
# or the quarantine that Visual Review applies, so a flaky story can fail a fork PR
# that changes nothing visible. --partial because a shard renders only part of the
# baseline, so a baseline missing from the shard is not a removal.
# The PR controls frontend/snapshots.yml and verify cannot check the HMAC offline, so
# this catches honest drift, not a forged baseline. Visual Review drops entries with a
# bad signature, so the merge queue still catches a forged one.
# !cancelled() keeps the check after a test-runner failure. A failed CLI build
# fails this step too, so a fork PR never passes unverified.
- name: Verify snapshots against the baseline offline
if: ${{ !cancelled() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
shell: bash
run: |
set +e
node vr-cli/products/visual_review/cli/dist/cli/src/index.js verify --partial \
--dir frontend/__snapshots__/ \
--baseline frontend/snapshots.yml > /tmp/vr-verify.log 2>&1
status=$?
set -e
cat /tmp/vr-verify.log
{
echo "### Offline snapshot check (${{ matrix.browser }} ${{ matrix.shard }}/${{ matrix.shard_count }})"
echo
echo "Fork PRs get no Visual Review run, so this shard compares its screenshots with \`frontend/snapshots.yml\` offline."
echo "This fallback accepts only an exact pixel match. It does not know the tolerated hashes or the quarantine of Visual Review, so a flaky story can also fail it."
echo
echo '```'
cat /tmp/vr-verify.log
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
if [ "$status" -ne 0 ] && grep -q 'Baseline mismatch' /tmp/vr-verify.log; then
echo "::error title=Snapshots differ from the baseline::This shard's screenshots do not match frontend/snapshots.yml. Fork PRs get no Visual Review run, so this offline fallback accepts only an exact pixel match, without the tolerated hashes or the quarantine of Visual Review. A maintainer must review the change and update the baseline before this PR can merge. The job log lists the snapshots that differ."
elif [ "$status" -ne 0 ]; then
echo "::error title=Offline snapshot check failed::The offline snapshot check could not compare this shard's screenshots. The job log shows the error."
fi
exit "$status"
# Saves the container version of the entry from one shard, master only. Runs
# after the upload so packing never delays a snapshot. !cancelled() because the
# tests run before the build, and a red test still warms a correct cache. The
# outcome check keeps a partial tree from taking the immutable key.
- name: Save VR CLI build
if: >-
!cancelled()
&& github.ref == 'refs/heads/master'
&& matrix.shard == 1
&& matrix.browser == 'chromium'
&& steps.build-vr-cli.outcome == 'success'
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
vr-cli/products/visual_review/cli/dist
vr-cli/products/visual_review/cli/node_modules
key: ${{ steps.restore-vr-cli.outputs.cache-primary-key }}
# The "Fail on test-runner failure" step below is the verdict, so a
# failed upload must not red a passing shard. ci-storybook-update-test-timing
# requires a complete set of shards, so it skips a run that loses one.
- name: Upload test results
if: always()
continue-on-error: true
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: junit-results-storybook-${{ matrix.browser }}-${{ matrix.shard }}
path: common/storybook/junit.xml
if-no-files-found: ignore
retention-days: 1
# Best-effort Trunk upload (continue-on-error); the "Fail on test-runner failure" step
# below is the verdict, so a Trunk outage can't red a passing shard. Internal PRs only
# (needs the secret).
# TRUNK_UPLOAD_ENABLED is the master kill-switch: when it is not 'true' this gate
# skips, so nothing uploads and no known flakes are quarantined. TRUNK_QUARANTINE_ENABLED
# splits the two — it defaults off, so set it to 'true' to let the verdict step below mask
# known flakes; otherwise any real failure reds the job (quarantining needs upload on too).
# chromium and webkit run the same stories, so each browser uploads under its own Trunk
# variant — one test id for both browsers would make a browser-specific failure look
# like a flake.
- name: Quarantine gate
id: quarantine_gate
continue-on-error: true
if: ${{ !cancelled() && env.RUNS_ON_INTERNAL_PR == 'true' && github.repository == 'PostHog/posthog' && github.actor != 'dependabot[bot]' && vars.TRUNK_UPLOAD_ENABLED == 'true' }}
uses: ./.github/actions/trunk-quarantine-gate
with:
junit-paths: common/storybook/junit.xml
test-collection-id: P3SjL3vy # storybook
variant: ${{ matrix.browser }}
previous-step-outcome: ${{ steps.test-runner.outcome }}
token: ${{ secrets.TRUNK_API_TOKEN }}
# Verdict: red a real failure the gate didn't clear as a quarantined flake. != 'success'
# also covers the skipped gate on fork/Dependabot.
- name: Fail on test-runner failure
if: ${{ !cancelled() && steps.test-runner.outcome == 'failure' && (vars.TRUNK_QUARANTINE_ENABLED != 'true' || steps.quarantine_gate.outcome != 'success') }}
shell: bash
run: exit 1
# Verify changed story files are stable by re-running them multiple times.
# Catches flaky snapshots before they land on master.
flake-verification:
name: Storybook flake verification
runs-on: ubuntu-24.04
needs: [changes, build-storybook]
# Skipped on trunk-merge/**: the job diffs against base.sha to find changed stories,
# which on a queue branch is the same set the PR's own run already re-ran three times.
if: |
needs.changes.outputs.frontend == 'true'
&& github.event_name == 'pull_request'
&& needs.changes.outputs.merge_queue != 'true'
timeout-minutes: 45
container:
image: ghcr.io/posthog/playwright:v1.60.0@sha256:83192064c7510f7ee73dd63dc5f22a5e01a92c81a2e6a9c715d9e3fe55471fd9
env:
NODE_OPTIONS: --max-old-space-size=16384
OPT_OUT_CAPTURE: 1
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Install pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Fix node-gyp permissions
run: find ~/setup-pnpm -name gyp_main.py -exec chmod +x {} +
- name: Mint setup-action GitHub token
id: setup-gh-token
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.GH_APP_POSTHOG_SETUP_ACTIONS_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_SETUP_ACTIONS_PRIVATE_KEY }}
skip-token-revoke: true
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: .nvmrc
token: ${{ steps.setup-gh-token.outputs.token || github.token }}
- name: Get pnpm store path
id: pnpm-store
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies
run: pnpm --filter=@posthog/storybook... install --frozen-lockfile
- name: Download Storybook build artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: storybook-build
path: common/storybook/dist
- name: Serve Storybook
# Resolve http-server/wait-on from common/storybook's devDependencies (no global install).
run: |
pnpm --filter=@posthog/storybook exec http-server dist --port 6006 --silent &
sleep 2
pnpm --filter=@posthog/storybook exec wait-on http://127.0.0.1:6006 --timeout 30
- name: Verify changed stories are stable
id: flake-verify
env:
HOME: /root
run: |
git config --global --add safe.directory '*'
BASE_SHA="${{ github.event.pull_request.base.sha }}"
git fetch --no-tags --prune --depth=50 origin "$BASE_SHA"
.github/scripts/verify-storybook-new-stories.sh "$BASE_SHA" 3
- name: Upload flake verification screenshots
if: failure() && steps.flake-verify.outcome == 'failure'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: flake-verification-diffs
path: |
frontend/__snapshots__/__diff_output__/
frontend/__snapshots__/__failures__/
if-no-files-found: ignore
retention-days: 5
# Collate matrix + VR completion status for the required check
visual_regression_tests:
needs: [changes, build-storybook, select-stories, visual-regression, vr-setup, vr-complete]
name: Visual regression tests pass
runs-on: ubuntu-24.04
timeout-minutes: 5
if: ${{ !cancelled() }}
steps:
- name: Check matrix outcome
run: |
# Change detection is the root of the graph: if it fails, build-storybook
# (and everything after) is skipped, which the checks below would read as
# a pass. Fail here so a broken paths-filter/checkout can't launder into a
# green required check.
if [[ "${{ needs.changes.result }}" != "success" && "${{ needs.changes.result }}" != "skipped" ]]; then
echo "Change detection did not succeed (result: ${{ needs.changes.result }}) — cannot certify visual regression."
exit 1
fi
# A failed, timed-out or cancelled Storybook build skips the VR jobs
# below rather than failing them, and this gate would read those skips
# as a pass — a green required check with zero visual coverage. Naming
# the build here makes its failure hit the gate directly. A legitimate
# "no frontend changes" run leaves the build skipped, which still passes.
if [[ "${{ needs.build-storybook.result }}" != "success" && "${{ needs.build-storybook.result }}" != "skipped" ]]; then
echo "Storybook build did not succeed (result: ${{ needs.build-storybook.result }}) — visual regression is uncovered."
exit 1
fi
if [[ "${{ needs.select-stories.result }}" != "success" && "${{ needs.select-stories.result }}" != "skipped" ]]; then
echo "Story selection did not succeed (result: ${{ needs.select-stories.result }}) — visual regression is uncovered."
exit 1
fi
if [[ "${{ needs.visual-regression.result }}" != "success" && "${{ needs.visual-regression.result }}" != "skipped" ]]; then
echo "One or more jobs in the visual-regression test matrix failed."
exit 1
fi
if [[ "${{ needs.vr-setup.result }}" != "success" && "${{ needs.vr-setup.result }}" != "skipped" ]]; then
echo "Visual Review setup failed or was cancelled (result: ${{ needs.vr-setup.result }})."
exit 1
fi
if [[ "${{ needs.vr-complete.result }}" != "success" && "${{ needs.vr-complete.result }}" != "skipped" ]]; then
echo "Visual Review did not complete successfully (result: ${{ needs.vr-complete.result }})."
exit 1
fi
echo "All jobs passed or were skipped successfully."
vr-complete:
name: Complete Visual Review run
runs-on: ubuntu-24.04
timeout-minutes: 15
needs: [changes, visual-regression, vr-setup]
# Intentional always(), not !cancelled(): this job finalizes external state,
# so a run cancelled after the matrix succeeded must still complete the VR
# run rather than leave its commit status stuck pending. It also stops the
# skipped select-stories job (transitively in the needs chain on ready PRs)
# from skip-propagating here. Legit skips still work: no-VR cases leave
# run_id empty, and a failed or cancelled matrix fails the result check.
if: |
always()
&& needs.vr-setup.outputs.run_id != ''
&& needs.visual-regression.result == 'success'
steps:
# Head checkout supplies the PR's snapshot baseline; the VR CLI itself is
# built from the base branch below.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
sparse-checkout: |
.nvmrc
frontend/snapshots.yml
sparse-checkout-cone-mode: false
# Build the VR CLI from the base branch, not the PR head (see vr-setup
# for the rationale).
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.ref || github.sha }}
path: vr-cli
sparse-checkout: |
products/visual_review/cli
products/visual_review/frontend/generated/api.schemas.ts
sparse-checkout-cone-mode: false
- name: Mint setup-action GitHub token
id: setup-gh-token
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.GH_APP_POSTHOG_SETUP_ACTIONS_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_SETUP_ACTIONS_PRIVATE_KEY }}
skip-token-revoke: true
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: .nvmrc
token: ${{ steps.setup-gh-token.outputs.token || github.token }}
# Keys the cached build the same way vr-setup does, and reads the host entry
# vr-setup writes. This job never saves it.
- name: Restore VR CLI build
id: restore-vr-cli
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
vr-cli/products/visual_review/cli/dist
vr-cli/products/visual_review/cli/node_modules
key: vr-cli-build-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', 'vr-cli/products/visual_review/cli/**', 'vr-cli/products/visual_review/frontend/generated/api.schemas.ts') }}
# Runs only on a miss, and retries the install for the reason given at vr-setup.
- name: Build VR CLI
if: steps.restore-vr-cli.outputs.cache-hit != 'true'
timeout-minutes: 8
shell: bash
working-directory: vr-cli/products/visual_review/cli
env:
npm_config_fetch_retries: 0
npm_config_fetch_timeout: 60000
run: |
npm ci --no-audit --no-fund || { echo "::warning::VR CLI install failed, retrying once"; sleep $((5 + RANDOM % 26)); npm ci --no-audit --no-fund; }
npm run build
npm prune --omit=dev
- name: Complete Visual Review run
# Pass the same --purpose vr-setup created the run with. The backend reports zero
# unresolved on an observe run however many snapshots drifted, so without the flag
# the CLI cannot tell a clean tracking run from a drifting one and reports neither.
#
# An observe run fails on drift. The merge-queue leg renders the merged tree that is
# about to land, and it is the only leg that renders every story, so drift there is
# the merge itself moving pictures that no reviewer approved. Quarantine an
# identifier that flips between runs; that excludes it from these counts.
#
# --tolerate-drift exempts master: no merge is left to stop there, and a red job
# would block the repair as well. Exit 2 is a CLI failure and reds CI everywhere.
env:
VR_TOKEN: ${{ secrets.VR_API_TOKEN }}
RUN_ID: ${{ needs.vr-setup.outputs.run_id }}
VR_OBSERVE: ${{ github.event_name == 'push' || needs.changes.outputs.merge_queue == 'true' }}
VR_TOLERATE_DRIFT: ${{ github.event_name == 'push' }}
# Recompute re-runs this job, which re-reads the verdict without a new capture.
JOB_CHECK_RUN_ID: ${{ job.check_run_id }}
run: |
args=(--run-id "$RUN_ID" --baseline frontend/snapshots.yml --token "$VR_TOKEN")
if [ "$VR_OBSERVE" = 'true' ]; then
args+=(--purpose observe)
fi
if [ "$VR_TOLERATE_DRIFT" = 'true' ]; then
args+=(--tolerate-drift)
fi
node vr-cli/products/visual_review/cli/dist/cli/src/index.js run complete "${args[@]}"
calculate-running-time:
name: Calculate running time
needs: [visual-regression, visual_regression_tests, changes]
runs-on: ubuntu-24.04
timeout-minutes: 5
if: # Run on pull requests to PostHog/posthog + on PostHog/posthog outside of PRs - but never on forks or Dependabot (no secrets access)
${{ !cancelled() && github.actor != 'dependabot[bot]' &&
needs.changes.outputs.frontend == 'true' && (
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'PostHog/posthog') ||
(github.event_name != 'pull_request' && github.repository == 'PostHog/posthog')) }}
permissions:
contents: read
actions: read
steps:
- name: Get telemetry app token
id: telemetry-app-token
if: github.run_attempt == '1'
continue-on-error: true
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.GH_APP_TELEMETRY_APP_ID }}
private-key: ${{ secrets.GH_APP_TELEMETRY_PRIVATE_KEY }}
- name: Capture running time to PostHog
if: github.run_attempt == '1'
continue-on-error: true
uses: PostHog/posthog-github-action@58dea254b598fb5d469c0699c98af8288a7f7650 # v1.2.0
with:
posthog-token: ${{ secrets.POSTHOG_API_TOKEN }}
event: 'posthog-ci-running-time'
capture-run-duration: true
capture-job-durations: true
github-token: ${{ steps.telemetry-app-token.outputs.token || github.token }}
status-job: 'Visual regression tests pass'
- name: Capture running time to DevEx PostHog
if: github.run_attempt == '1'
continue-on-error: true
uses: PostHog/posthog-github-action@58dea254b598fb5d469c0699c98af8288a7f7650 # v1.2.0
with:
posthog-token: ${{ secrets.POSTHOG_DEVEX_PROJECT_API_TOKEN }}
event: 'posthog-ci-running-time'
capture-run-duration: true
capture-job-durations: true
github-token: ${{ steps.telemetry-app-token.outputs.token || github.token }}
status-job: 'Visual regression tests pass'
capture-story-selection:
name: Capture story selection
needs: [select-stories]
runs-on: ubuntu-24.04
timeout-minutes: 5
# select-stories skips merge-queue and forced-full runs. Fire only when it
# produced a decision. DevEx project only; continue-on-error so telemetry never reds CI.
if: |
!cancelled() && github.run_attempt == '1'
&& github.actor != 'dependabot[bot]'
&& github.event.pull_request.head.repo.full_name == 'PostHog/posthog'
&& needs.select-stories.result == 'success'
steps:
- name: Capture story selection to DevEx PostHog
continue-on-error: true
uses: PostHog/posthog-github-action@58dea254b598fb5d469c0699c98af8288a7f7650 # v1.2.0
with:
posthog-token: ${{ secrets.POSTHOG_DEVEX_PROJECT_API_TOKEN }}
event: 'posthog-ci-test-selection'
properties: |
{
"suite": "storybook",
"mode": ${{ toJSON(needs.select-stories.outputs.mode) }},
"narrowed": ${{ needs.select-stories.outputs.narrowed || 'false' }},
"skip_reason": ${{ toJSON(needs.select-stories.outputs.skip_reason || '') }},
"total_story_count": ${{ needs.select-stories.outputs.total_story_count || 'null' }},
"affected_story_count": ${{ needs.select-stories.outputs.affected_story_count || 'null' }},
"suggested_shards": ${{ needs.select-stories.outputs.suggested_shards || 'null' }},
"estimated_minutes_saved": ${{ needs.select-stories.outputs.estimated_minutes_saved || 'null' }},
"event_type": ${{ toJSON(github.event_name) }},
"branch": ${{ toJSON(github.head_ref || github.ref_name) }},
"sha": ${{ toJSON(github.sha) }},
"pr_number": ${{ github.event.pull_request.number || 'null' }},
"run_id": ${{ toJSON(github.run_id) }}
}
# Pick a narrowed visual-regression matrix for PRs based on which stories the Vite
# module graph says are affected by the diff. Merge-queue runs and PRs with the
# run-ci-frontend label skip this job and use the full chromium matrix. Selector
# uncertainty also falls back to the full matrix.
select-stories:
name: Story selection
runs-on: ubuntu-24.04
timeout-minutes: 5
needs: [changes, build-storybook]
if: |
!cancelled() && needs.changes.outputs.frontend == 'true'
&& github.event_name == 'pull_request'
&& needs.changes.outputs.merge_queue != 'true'
&& !contains(github.event.pull_request.labels.*.name, 'run-ci-frontend')
outputs:
mode: ${{ steps.classify.outputs.mode }}
matrix: ${{ steps.classify.outputs.matrix }}
should_run: ${{ steps.classify.outputs.should_run }}
affected_files_json: ${{ steps.classify.outputs.affected_files_json }}
# Telemetry for the capture-story-selection job below.
narrowed: ${{ steps.classify.outputs.narrowed }}
skip_reason: ${{ steps.classify.outputs.skip_reason }}
total_story_count: ${{ steps.classify.outputs.total_story_count }}
affected_story_count: ${{ steps.classify.outputs.affected_story_count }}
suggested_shards: ${{ steps.classify.outputs.suggested_shards }}
estimated_minutes_saved: ${{ steps.classify.outputs.estimated_minutes_saved }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
filter: blob:none
fetch-depth: 500
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Check out trusted story selector
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
filter: blob:none
fetch-depth: 1
path: story-selector
ref: ${{ github.event.pull_request.base.ref }}
repository: ${{ github.event.pull_request.base.repo.full_name }}
sparse-checkout: bin/find-affected-stories
sparse-checkout-cone-mode: false
- name: Mint setup-action GitHub token
id: setup-gh-token
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.GH_APP_POSTHOG_SETUP_ACTIONS_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_SETUP_ACTIONS_PRIVATE_KEY }}
skip-token-revoke: true
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: .nvmrc
token: ${{ steps.setup-gh-token.outputs.token || github.token }}
- name: Download module-graph artifact
id: download
continue-on-error: true
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: storybook-module-graph
path: /tmp/storybook-graph
- name: Compute affected stories
id: selection
continue-on-error: true
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }}
run: |
BASE_SHA=$(git -C story-selector rev-parse HEAD)
git fetch --no-tags --depth=500 --filter=blob:none \
"${{ github.server_url }}/$BASE_REPO.git" \
"$BASE_SHA:refs/remotes/base/$BASE_REF"
echo "### Changed files" >&2
git diff --name-only "base/$BASE_REF"...HEAD >&2
RESULT=$(story-selector/bin/find-affected-stories \
--graph /tmp/storybook-graph/module-graph.json \
--base-ref "base/$BASE_REF")
echo "$RESULT" > /tmp/selection-result.json
# Stash compact form in step output (single line) for the summary step.
COMPACT=$(node -e 'const d = JSON.parse(require("fs").readFileSync("/tmp/selection-result.json", "utf8")); delete d.affected_stories; delete d.unresolved_changed_files; process.stdout.write(JSON.stringify(d))')
echo "result=$COMPACT" >> "$GITHUB_OUTPUT"
- name: Build narrowed matrix
id: classify
env:
SELECT_OUTCOME: ${{ steps.selection.outcome }}
run: |
set -euo pipefail
# Selection telemetry (see the capture-story-selection job). Best-effort
# story counts from the selector JSON; narrowed=false marks a skip/fallback.
emit_metrics() {
local total="" affected="" shards=""
if [[ -s /tmp/selection-result.json ]]; then
total=$(jq -r '.total_story_count // empty' /tmp/selection-result.json)
affected=$(jq -r '.affected_story_count // empty' /tmp/selection-result.json)
shards=$(jq -r '(([.suggested_shards, 16] | min) // empty)' /tmp/selection-result.json)
fi
{
echo "narrowed=$1"
echo "skip_reason=$2"
echo "total_story_count=$total"
echo "affected_story_count=$affected"
echo "suggested_shards=$shards"
echo "estimated_minutes_saved=${3:-}"
} >> "$GITHUB_OUTPUT"
}
fall_back_to_full() {
echo "mode=full" >> "$GITHUB_OUTPUT"
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "affected_files_json=[]" >> "$GITHUB_OUTPUT"
emit_metrics false "$1"
}
if [[ "$SELECT_OUTCOME" != "success" ]] || [[ ! -s /tmp/selection-result.json ]]; then
echo "::warning::story selector did not produce output; running the full visual regression matrix"
fall_back_to_full selector_error
exit 0
fi
MODE=$(jq -r '.mode' /tmp/selection-result.json)
if [[ "$MODE" != "selective" ]]; then
echo "Selector declined to narrow (mode=$MODE); running the full visual regression matrix"
fall_back_to_full "$(jq -r '.reason // "full_requested"' /tmp/selection-result.json)"
exit 0
fi
COUNT=$(jq -r '.affected_story_count' /tmp/selection-result.json)
if [[ "$COUNT" -eq 0 ]]; then
echo "mode=selective" >> "$GITHUB_OUTPUT"
echo 'matrix={"include":[]}' >> "$GITHUB_OUTPUT"
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "affected_files_json=[]" >> "$GITHUB_OUTPUT"
emit_metrics true no_affected_stories
echo "Selective mode: 0 affected stories, skipping visual regression"
exit 0
fi
# Size the narrowed matrix from the estimated affected workload.
AFFECTED_FILES_JSON=$(jq -c '.affected_stories' /tmp/selection-result.json)
SHARDS=$(jq -r '[.suggested_shards, 16] | min' /tmp/selection-result.json)
MATRIX=$(jq -cn --argjson count "$SHARDS" \
'{include: [range(1; $count + 1) | {browser: "chromium", shard_count: $count, shard: .}]}')
echo "mode=selective" >> "$GITHUB_OUTPUT"
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "affected_files_json=$AFFECTED_FILES_JSON" >> "$GITHUB_OUTPUT"
# Est. minutes saved vs the full 16-shard run — same formula as the
# "Write summary" step (CURRENT_SHARDS=16, SHARD_OVERHEAD_MINUTES=3).
AFFECTED_DUR=$(jq -r '.affected_duration_seconds' /tmp/selection-result.json)
TOTAL_DUR=$(jq -r '.total_duration_seconds' /tmp/selection-result.json)
FULL_MINUTES=$(( (16 * 3) + (TOTAL_DUR / 60) ))
SELECTIVE_MINUTES=$(( (SHARDS * 3) + (AFFECTED_DUR / 60) ))
emit_metrics true "" "$(( FULL_MINUTES - SELECTIVE_MINUTES ))"
echo "Selective mode: $COUNT affected story files across $SHARDS shard(s)"
- name: Write summary
if: steps.selection.outcome == 'success'
env:
RESULT: ${{ steps.selection.outputs.result }}
CURRENT_SHARDS: 16
SHARD_OVERHEAD_MINUTES: 3
run: |
MODE=$(echo "$RESULT" | jq -r '.mode')
TOTAL=$(echo "$RESULT" | jq -r '.total_story_count')
if [ "$MODE" = "selective" ]; then
COUNT=$(echo "$RESULT" | jq -r '.affected_story_count')
AFFECTED_DUR=$(echo "$RESULT" | jq -r '.affected_duration_seconds')
TOTAL_DUR=$(echo "$RESULT" | jq -r '.total_duration_seconds')
SHARDS=$(echo "$RESULT" | jq -r '[.suggested_shards, 16] | min')
SAVED_STORIES=$((TOTAL - COUNT))
SAVED_SHARDS=$((CURRENT_SHARDS - SHARDS))
FULL_MINUTES=$(( (CURRENT_SHARDS * SHARD_OVERHEAD_MINUTES) + (TOTAL_DUR / 60) ))
SELECTIVE_MINUTES=$(( (SHARDS * SHARD_OVERHEAD_MINUTES) + (AFFECTED_DUR / 60) ))
SAVED_MINUTES=$((FULL_MINUTES - SELECTIVE_MINUTES))
cat >> "$GITHUB_STEP_SUMMARY" << EOF
## Story selection
| Metric | Full run | Selective | Saved |
|---|---|---|---|
| Story files | $TOTAL | $COUNT | **$SAVED_STORIES** |
| Shards | $CURRENT_SHARDS | $SHARDS | **$SAVED_SHARDS** |
| Est. CI minutes | ~${FULL_MINUTES} | ~${SELECTIVE_MINUTES} | **~${SAVED_MINUTES}** |
| Est. render time | ${TOTAL_DUR}s | ${AFFECTED_DUR}s | **$((TOTAL_DUR - AFFECTED_DUR))s** |
> Predictor: Vite module graph from build-storybook. Shared code and uncertain changes run the full suite.
EOF
echo "SELECTION_METRICS: $(echo "$RESULT" | jq -c '{mode, affected: .affected_story_count, total: .total_story_count, current_shards: '$CURRENT_SHARDS', affected_dur: .affected_duration_seconds, total_dur: .total_duration_seconds}')"
else
REASON=$(echo "$RESULT" | jq -r '.reason // "unknown"')
cat >> "$GITHUB_STEP_SUMMARY" << EOF
## Story selection
**Full run required:** $REASON
Total stories: $TOTAL
EOF
echo "SELECTION_METRICS: $(echo "$RESULT" | jq -c '{mode, reason, total: .total_story_count}')"
fi