trunk-merge/pr-110452/e2121c63-277d-4dc6-be10-e7b1ae327a8a #353194
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |