feat(data-warehouse): implement demodesk import source #356031
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
| # | |
| # This workflow runs CI E2E tests with Playwright. | |
| # | |
| # It relies on the posthog-node image built by 'ci-nodejs-container.yml'. | |
| # | |
| name: E2E CI Playwright | |
| on: | |
| pull_request: | |
| # Draft PRs skip E2E entirely — the broad path filter below matches nearly | |
| # every code PR, and a full stack boot per draft push is the | |
| # remaining big per-PR cost. Ready PRs run the specs the diff can affect | |
| # (see select-specs); the merge queue's trunk-merge/** run is the full suite | |
| # that gates master. Use workflow_dispatch to force a run on a draft. | |
| types: [opened, synchronize, reopened, ready_for_review] | |
| workflow_dispatch: | |
| inputs: | |
| playwright_retries: | |
| description: 'Override Playwright retries (leave empty for the normal CI default of 1; use 0 to expose raw failures).' | |
| required: false | |
| type: string | |
| default: '' | |
| push: | |
| branches: | |
| - master | |
| permissions: | |
| contents: read | |
| env: | |
| SECRET_KEY: '6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da' # unsafe - for testing only | |
| REDIS_URL: redis://localhost | |
| DATABASE_URL: postgres://posthog:posthog@localhost:5432/posthog_e2e_test | |
| PERSONS_DB_WRITER_URL: postgres://posthog:posthog@localhost:5432/posthog_persons_e2e_test | |
| KAFKA_HOSTS: kafka:9092 | |
| DISABLE_SECURE_SSL_REDIRECT: 1 | |
| SECURE_COOKIES: 0 | |
| OPT_OUT_CAPTURE: 0 | |
| E2E_TESTING: 1 | |
| SKIP_SERVICE_VERSION_REQUIREMENTS: 1 | |
| EMAIL_HOST: email.test.posthog.net | |
| SITE_URL: http://localhost:8000 | |
| NO_RESTART_LOOP: 1 | |
| OBJECT_STORAGE_ENABLED: 1 | |
| OBJECT_STORAGE_ENDPOINT: http://localhost:19000 | |
| OBJECT_STORAGE_ACCESS_KEY_ID: object_storage_root_user | |
| OBJECT_STORAGE_SECRET_ACCESS_KEY: object_storage_root_password | |
| GITHUB_ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| CELERY_METRICS_PORT: 8999 | |
| CLOUD_DEPLOYMENT: E2E | |
| CLICKHOUSE_HOST: 'localhost' | |
| CLICKHOUSE_SECURE: 'False' | |
| CLICKHOUSE_VERIFY: 'False' | |
| CLICKHOUSE_DATABASE: posthog_test | |
| # Database names passed to the plugins Docker service via env var substitution | |
| POSTHOG_DB_NAME: posthog_e2e_test | |
| POSTHOG_PERSONS_DB_NAME: posthog_persons_e2e_test | |
| PGHOST: localhost | |
| PGUSER: posthog | |
| PGPASSWORD: posthog | |
| PGPORT: 5432 | |
| # this is a fake key so this workflow can run for external contributors as they do not have access to secrets (that we don't need here) | |
| OIDC_RSA_PRIVATE_KEY: ${{ vars.OIDC_RSA_FAKE_PRIVATE_KEY }} | |
| PERSONHOG_ADDR: 'localhost:50052' | |
| # Internal API auth needs a non-empty INTERNAL_API_SECRET when DEBUG/TEST are off. The dev value | |
| # matches the Node plugins container's non-prod default, so Django<->Node internal calls authenticate. | |
| INTERNAL_API_SECRET: posthog123 | |
| 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' | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| 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. | |
| # Drafts skip E2E entirely, so a verdict recorded on one would have no run to check against. | |
| 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 | |
| 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 }} | |
| changes: | |
| runs-on: ubuntu-24.04 | |
| # 7 not 5: the debounce step below sleeps up to 60s, so widen the budget to | |
| # keep ample headroom. A timeout here skips Playwright, which the aggregator | |
| # would count as a pass. | |
| timeout-minutes: 7 | |
| # Run on master push, manual dispatch, and on ready internal-repo PRs. | |
| # Drafts skip E2E (see the trigger comment); the `Playwright tests pass` | |
| # aggregator treats skipped as success, and drafts can't merge anyway. | |
| if: | | |
| github.event_name == 'push' || | |
| github.event_name == 'workflow_dispatch' || | |
| (github.event.pull_request.head.repo.full_name == github.repository && | |
| (github.event.pull_request.draft != true || | |
| startsWith(github.head_ref, 'trunk-merge/'))) | |
| name: Determine need to run E2E checks | |
| outputs: | |
| # Debounce can veto a PR run whose SHA has already been superseded (see below). | |
| shouldRun: ${{ steps.decide.outputs.shouldRun == 'true' && steps.debounce.outputs.superseded != 'true' }} | |
| oldest_supported: ${{ steps.read-versions.outputs.oldest_supported }} | |
| schema_cache_key: ${{ steps.schema-key.outputs.key }} | |
| schema_migrations_key: ${{ steps.schema-key.outputs.migrations_key }} | |
| schema_prefix_key: ${{ steps.schema-key.outputs.prefix_key }} | |
| steps: | |
| # fetch-depth=1000 + blob:none mirrors ci-backend / ci-dagster so HEAD^2 | |
| # (PR branch tip) is reachable for the merge-base step below without the | |
| # cost of fetching blobs. | |
| # | |
| # sparse-checkout works around an actions/checkout@v6.0.2 flake: with | |
| # blob:none, the post-fetch `git checkout` lazy-fetches every blob in HEAD | |
| # and the per-blob credential lookup intermittently fails with "could not | |
| # read Username for github.com" (e.g. #59779 on 2026-05-23, blocked merge | |
| # until the job was retried). Materializing only the files this job reads | |
| # (plus the local paths-filter action, which must exist on disk to run) | |
| # collapses the lazy fetch to a handful of blobs. | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 1000 | |
| filter: blob:none | |
| clean: false | |
| sparse-checkout: | | |
| .github/actions/paths-filter | |
| .github/clickhouse-versions.json | |
| docker-compose.base.yml | |
| 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: changes | |
| if: github.event_name == 'pull_request' | |
| with: | |
| token: ${{ steps.app-token.outputs.token || github.token }} | |
| filters: | | |
| shouldRun: | |
| # Any change that could affect E2E behavior triggers a run. | |
| - playwright/** | |
| # The whole product frontend, not only the specs it owns: it compiles | |
| # into the same bundle the suite drives. tools/playwright_area_map.json | |
| # narrows the run from here. | |
| - 'products/*/frontend/**' | |
| - .github/workflows/ci-e2e-playwright.yml | |
| - 'ee/**' | |
| - 'posthog/!(temporal/**)/**' | |
| # The personhog gRPC stubs the backend's person reads go through | |
| - 'packages/personhog-proto/**' | |
| - 'bin/*' | |
| - frontend/**/* | |
| - package.json | |
| - pnpm-lock.yaml | |
| - uv.lock | |
| - .github/clickhouse-versions.json | |
| - docker-compose.dev.yml | |
| - Dockerfile | |
| # Spec-selection selector + map: changes must run the suite (they're | |
| # force-full anyway) so the map validation step actually executes. | |
| - tools/playwright_spec_selection.py | |
| - tools/playwright_area_map.json | |
| - tools/test_playwright_spec_selection.py | |
| # Docs-only changes match the broad globs above but exercise no | |
| # code the E2E suite runs — exclude them so a docs-only PR doesn't | |
| # boot the whole stack. | |
| - '!**/*.md' | |
| - '!**/*.mdx' | |
| # Same for Django's pytest modules: the suite drives the app through a | |
| # browser and never loads them. Narrow on purpose: | |
| # - modules, not whole test/ dirs: posthog/test/ also holds live | |
| # server code, notably playwright_setup_functions.py, which | |
| # posthog/urls.py serves at api/setup_test/. | |
| # - no '*_test.py': under posthog/ that suffix names production code | |
| # (ClickHouse migrations, ClickHouse UDF scripts), not tests. | |
| # - per-tree prefixes, because an exclude is tested against every file | |
| # the filter sees, not just the include it sits next to: a bare | |
| # '!**/test_*.py' would also veto the include above for | |
| # tools/test_playwright_spec_selection.py. | |
| - '!posthog/**/test_*.py' | |
| - '!posthog/**/conftest.py' | |
| - '!ee/**/test_*.py' | |
| - '!ee/**/conftest.py' | |
| # Visual Review auto-commits a snapshot baseline after human review and CI | |
| # approval. Re-running the full E2E stack for that bot push is pure waste; | |
| # the `playwright_tests` aggregator treats the resulting skip as success. Key | |
| # off the push delta (this push's commits), not the whole-PR diff the filter reports, | |
| # so it fires even when the PR also carries real code changes. | |
| - 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" | |
| - name: Decide whether to run Playwright | |
| id: decide | |
| env: | |
| IS_PUSH: ${{ github.event_name == 'push' }} | |
| IS_DISPATCH: ${{ github.event_name == 'workflow_dispatch' }} | |
| SHOULD_RUN: ${{ steps.changes.outputs.shouldRun }} | |
| IS_BASELINE_ONLY: ${{ steps.baseline_push.outputs.result }} | |
| run: | | |
| if [[ "$IS_BASELINE_ONLY" == "true" ]]; then | |
| echo "shouldRun=false" >> "$GITHUB_OUTPUT" | |
| elif [[ "$IS_PUSH" == "true" || "$IS_DISPATCH" == "true" || "$SHOULD_RUN" == "true" ]]; then | |
| echo "shouldRun=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "shouldRun=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Debounce rapid PR pushes | |
| # A burst of pushes to one branch each dispatches a run; cancel-in-progress | |
| # kills the superseded ones, but only after they've begun booting the 8-core | |
| # stack. Hold here on the cheap 1x runner through the likely-supersession | |
| # window — if a newer push cancels this run mid-wait, the expensive job never | |
| # launches. After the wait, if the PR head has already moved past this run's | |
| # SHA, skip: the newer run is the real gate (and always runs, since the tip | |
| # is never stale against itself). Single pushes just pay the short wait. | |
| id: debounce | |
| if: steps.decide.outputs.shouldRun == 'true' && github.event_name == 'pull_request' | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} | |
| REPO: ${{ github.repository }} | |
| PR: ${{ github.event.pull_request.number }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| sleep 60 | |
| tip=$(gh api "repos/${REPO}/pulls/${PR}" --jq '.head.sha' 2>/dev/null || echo "") | |
| if [ -n "$tip" ] && [ "$tip" != "$HEAD_SHA" ]; then | |
| echo "::notice::PR head moved ($HEAD_SHA -> $tip); skipping — the newer run is the gate" | |
| echo "superseded=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "superseded=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Read ClickHouse versions from JSON | |
| id: read-versions | |
| if: steps.decide.outputs.shouldRun == 'true' | |
| run: | | |
| oldest_supported=$(jq -r '.oldest_supported' .github/clickhouse-versions.json) | |
| if [ -z "$oldest_supported" ] || [ "$oldest_supported" = "null" ]; then | |
| echo "::error::No oldest_supported version found in .github/clickhouse-versions.json" | |
| exit 1 | |
| fi | |
| echo "oldest_supported=$oldest_supported" >> $GITHUB_OUTPUT | |
| - name: Fetch base branch for merge-base computation | |
| if: steps.decide.outputs.shouldRun == 'true' && github.event_name == 'pull_request' | |
| env: | |
| BASE_REF: ${{ github.event.pull_request.base.ref }} | |
| # Scoped, blob-less, no-tags — matches ci-backend / ci-dagster. | |
| # Without an explicit refspec, `git fetch --deepen` would fall back to | |
| # remote.origin.fetch and pull every branch. | |
| run: git fetch --no-tags --depth=1000 --filter=blob:none origin "$BASE_REF:refs/remotes/origin/$BASE_REF" | |
| - name: Compute schema cache keys | |
| id: schema-key | |
| # Both PRs and master pushes restore the schema dump ci-backend saves on | |
| # master push, skipping the replay of the full migration history — | |
| # the migrate step in the heavy job still layers on anything newer. On a PR | |
| # the migration-set key is the merge-base's; on a master push it's HEAD's. | |
| # ci-backend already validates the full history on every master push (it's | |
| # what saves the cache), so replaying it here too bought nothing. A cache | |
| # miss falls through to a full migrate, so an absent/wrong key is never a | |
| # correctness risk. | |
| if: steps.decide.outputs.shouldRun == 'true' && (github.event_name == 'pull_request' || github.event_name == 'push') | |
| env: | |
| BASE_REF: ${{ github.event.pull_request.base.ref }} | |
| IS_PUSH: ${{ github.event_name == 'push' }} | |
| # Manual cache-bust knob — keep in sync with SCHEMA_CACHE_EPOCH in | |
| # ci-backend.yml, which saves these caches on master pushes. | |
| SCHEMA_CACHE_EPOCH: v2 | |
| run: | | |
| if [ "$IS_PUSH" = "true" ]; then | |
| # Pushed commit is checked out directly — no merge commit, no base ref. | |
| # The per-SHA key points at this same push's cache (saved concurrently | |
| # by ci-backend, so racy); rely on the stable migration-set key below. | |
| REF=HEAD | |
| else | |
| # HEAD is the synthetic merge commit; HEAD^2 is the PR branch tip. | |
| MERGE_BASE=$(git merge-base HEAD^2 "origin/${BASE_REF}" 2>/dev/null || echo "") | |
| if [ -z "$MERGE_BASE" ]; then | |
| echo "key=" >> $GITHUB_OUTPUT | |
| echo "migrations_key=" >> $GITHUB_OUTPUT | |
| echo "::notice::merge-base not found (branch too stale?) — schema cache will be skipped" | |
| exit 0 | |
| fi | |
| # The consumers replay the routed labels from the checked-out copy, so a PR | |
| # that edits any of them matches no master dump, the merge-base one included: | |
| # drop both keys and let migrations run from scratch. | |
| DB_ROUTING_BASE=$(git ls-tree -r --format='%(objectname) %(path)' "$MERGE_BASE" -- products/db_routing.yaml posthog/product_db_config.py posthog/product_db_router.py) | |
| DB_ROUTING_HEAD=$(git ls-tree -r --format='%(objectname) %(path)' HEAD -- products/db_routing.yaml posthog/product_db_config.py posthog/product_db_router.py) | |
| if [ "$DB_ROUTING_BASE" != "$DB_ROUTING_HEAD" ]; then | |
| echo "key=" >> $GITHUB_OUTPUT | |
| echo "migrations_key=" >> $GITHUB_OUTPUT | |
| echo "::notice::db routing config or router differs from the merge base, so the schema cache is skipped" | |
| exit 0 | |
| fi | |
| echo "key=posthog-schema-master-${MERGE_BASE}" >> $GITHUB_OUTPUT | |
| REF="$MERGE_BASE" | |
| fi | |
| # Last-resort restore prefix: matches the newest saved dump of any | |
| # migration set, for the window where both exact keys miss. Gated on | |
| # checkout age (a day) because a re-run of an old run keeps its original | |
| # commit: a stale checkout can lack migrations the newest dump records, | |
| # and the migrate top-ups only apply forward, so they cannot repair a | |
| # dump that is newer than the code. | |
| HEAD_AGE_SECONDS=$(( $(date +%s) - $(git show -s --format=%ct HEAD) )) | |
| if [ "$HEAD_AGE_SECONDS" -lt 86400 ]; then | |
| echo "prefix_key=posthog-schema-mig-${SCHEMA_CACHE_EPOCH}-" >> $GITHUB_OUTPUT | |
| fi | |
| # Shared migration-set key: mirrors the save-side computation in | |
| # ci-backend.yml (same inputs, hash, and prefix) so it hits whenever | |
| # the ref's migration files match a saved master schema — far more | |
| # stable than the per-SHA key, which needs that exact commit's cache | |
| # to still be alive. | |
| MIG_FILES=$(git ls-tree -r --format='%(objectname) %(path)' "$REF" \ | |
| | grep -E '/migrations/[^/]+\.py$|^[0-9a-f]+ uv\.lock$' \ | |
| | grep -vE '/(clickhouse|async_migrations)/migrations/' \ | |
| | LC_ALL=C sort) || true | |
| # Routing decides which app labels are in the dump, so these inputs belong in the key. | |
| DB_ROUTING=$(git ls-tree -r --format='%(objectname) %(path)' "$REF" -- products/db_routing.yaml posthog/product_db_config.py posthog/product_db_router.py) | |
| PG_IMAGE=$(git show "${REF}:docker-compose.base.yml" 2>/dev/null \ | |
| | grep -m1 -E '^[[:space:]]*image:[[:space:]]*postgres:' | tr -d '[:space:]') | |
| if [ -z "$MIG_FILES" ]; then | |
| echo "migrations_key=" >> $GITHUB_OUTPUT | |
| echo "::notice::no migration files matched — restore will fall back to the SHA schema key" | |
| else | |
| MIG_HASH=$(printf '%s\n%s\n%s' "$MIG_FILES" "$PG_IMAGE" "$DB_ROUTING" | sha256sum | cut -c1-40) | |
| echo "migrations_key=posthog-schema-mig-${SCHEMA_CACHE_EPOCH}-${MIG_HASH}" >> $GITHUB_OUTPUT | |
| fi | |
| select-specs: | |
| name: Select affected E2E specs | |
| needs: [changes] | |
| # Narrow the suite on any event of a ready (non-draft) internal PR: opened, | |
| # reopened, synchronize, and ready_for_review all diff the same head against | |
| # the same base, so they all select the same specs. workflow_dispatch and | |
| # push to master skip this job, leaving empty outputs so the playwright job | |
| # runs the FULL suite. | |
| # `draft != true` also keeps the merge queue on the full suite: its | |
| # trunk-merge/** PRs open as drafts, and that run is the gate for master, so | |
| # a selection miss is caught there. The post-merge master run is full too. | |
| # Fail-open is FULL, never skip: over-selection would silently drop coverage. | |
| if: | | |
| needs.changes.outputs.shouldRun == 'true' | |
| && github.event_name == 'pull_request' | |
| && github.event.pull_request.draft != true | |
| && github.event.pull_request.head.repo.full_name == github.repository | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 5 | |
| outputs: | |
| mode: ${{ steps.classify.outputs.mode }} | |
| spec_files: ${{ steps.classify.outputs.spec_files }} | |
| # Telemetry (see the capture-run-time job): empty when the selector errored. | |
| selected_count: ${{ steps.classify.outputs.selected_count }} | |
| total_spec_count: ${{ steps.classify.outputs.total_spec_count }} | |
| skipped_count: ${{ steps.classify.outputs.skipped_count }} | |
| changed_file_count: ${{ steps.classify.outputs.changed_file_count }} | |
| full_run_reason_category: ${{ steps.classify.outputs.full_run_reason_category }} | |
| full_run_reason_detail: ${{ steps.classify.outputs.full_run_reason_detail }} | |
| steps: | |
| # Full checkout at PR head so the selector can glob specs on disk and read | |
| # the map/script from the branch under test. Bounded depth + blob:none keeps | |
| # the merge-base with the base branch reachable cheaply. | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| repository: ${{ github.event.pull_request.head.repo.full_name }} | |
| fetch-depth: 1000 | |
| filter: blob:none | |
| - name: Run spec selector | |
| id: select | |
| # continue-on-error: a selector crash, a git failure, or a missing | |
| # script/map on an unrebased branch must NOT fail the job — the classify | |
| # step reads steps.select.outcome and falls open to a full run. | |
| continue-on-error: true | |
| env: | |
| BASE_REF: ${{ github.event.pull_request.base.ref }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # Fetch the current tip of the base branch (scoped, blobless, no tags), | |
| # then diff origin/$BASE_REF...HEAD (3-dot = merge-base) so master | |
| # commits merged into the branch aren't counted as changes. | |
| # This runs before Python, so tag a fetch failure with its own detail — | |
| # otherwise classify sees only a failed step and reports an empty detail. | |
| if ! git fetch --no-tags --depth=1000 --filter=blob:none origin "$BASE_REF:refs/remotes/origin/$BASE_REF"; then | |
| echo "::warning::git fetch of base ref failed — full run" | |
| echo "reason_detail=git_fetch_failed" >> "$GITHUB_OUTPUT" | |
| exit 1 | |
| fi | |
| if [ ! -f tools/playwright_spec_selection.py ] || [ ! -f tools/playwright_area_map.json ]; then | |
| echo "::notice::spec selector/map absent on this checkout (unrebased branch) — full run" | |
| exit 1 | |
| fi | |
| python3 tools/playwright_spec_selection.py \ | |
| --base-ref "origin/$BASE_REF" \ | |
| --summary-path "$GITHUB_STEP_SUMMARY" \ | |
| > /tmp/spec-selection.json | |
| - name: Classify selection | |
| id: classify | |
| env: | |
| SELECT_OUTCOME: ${{ steps.select.outcome }} | |
| SELECT_REASON_DETAIL: ${{ steps.select.outputs.reason_detail }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # Metric outputs are emitted in every path (see the capture-run-time | |
| # telemetry step). They stay empty only when the selector itself errored. | |
| SPECS="" | |
| SELECTED_COUNT="" | |
| TOTAL_COUNT="" | |
| SKIPPED_COUNT="" | |
| CHANGED_COUNT="" | |
| REASON_CATEGORY="" | |
| REASON_DETAIL="" | |
| if [[ "$SELECT_OUTCOME" != "success" ]] || [[ ! -s /tmp/spec-selection.json ]]; then | |
| echo "::warning::spec selector produced no output — running the FULL suite" | |
| MODE=full | |
| # Distinguish an unrebased branch (script/map not present yet — expected, | |
| # self-resolves on rebase) from a genuine selector failure worth investigating. | |
| if [[ ! -f tools/playwright_spec_selection.py ]] || [[ ! -f tools/playwright_area_map.json ]]; then | |
| REASON_CATEGORY=selector_unavailable | |
| else | |
| REASON_CATEGORY=selector_error | |
| # A bash-stage fetch failure exits before Python and tags itself here; | |
| # the Python paths carry their own detail in the JSON below. | |
| REASON_DETAIL="${SELECT_REASON_DETAIL:-}" | |
| fi | |
| else | |
| MODE=$(jq -r '.mode' /tmp/spec-selection.json) | |
| SELECTED_COUNT=$(jq -r '.selected_count' /tmp/spec-selection.json) | |
| TOTAL_COUNT=$(jq -r '.total_spec_count' /tmp/spec-selection.json) | |
| CHANGED_COUNT=$(jq -r '.changed_file_count' /tmp/spec-selection.json) | |
| REASON_CATEGORY=$(jq -r '.full_run_reason_category' /tmp/spec-selection.json) | |
| REASON_DETAIL=$(jq -r '.full_run_reason_detail // ""' /tmp/spec-selection.json) | |
| SKIPPED_COUNT=$((TOTAL_COUNT - SELECTED_COUNT)) | |
| if [[ "$MODE" == "selected" ]]; then | |
| SPECS=$(jq -r '.spec_files | join(" ")' /tmp/spec-selection.json) | |
| if [[ -z "$SPECS" ]]; then | |
| echo "::warning::selected mode but empty spec list — full run" | |
| MODE=full | |
| REASON_CATEGORY=no_specs | |
| fi | |
| else | |
| echo "Selector requested a full run:" | |
| jq -r '.full_run_reasons[]?' /tmp/spec-selection.json | |
| fi | |
| fi | |
| # On a full run nothing is passed downstream — the playwright job runs everything. | |
| if [[ "$MODE" != "selected" ]]; then | |
| SPECS="" | |
| fi | |
| { | |
| echo "mode=$MODE" | |
| echo "spec_files=$SPECS" | |
| echo "selected_count=$SELECTED_COUNT" | |
| echo "total_spec_count=$TOTAL_COUNT" | |
| echo "skipped_count=$SKIPPED_COUNT" | |
| echo "changed_file_count=$CHANGED_COUNT" | |
| echo "full_run_reason_category=$REASON_CATEGORY" | |
| echo "full_run_reason_detail=$REASON_DETAIL" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "mode=$MODE category=${REASON_CATEGORY:-none} selected=${SELECTED_COUNT:-?}/${TOTAL_COUNT:-?}" | |
| - name: Upload selection artifact | |
| if: always() && steps.select.outcome == 'success' | |
| uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 | |
| with: | |
| name: playwright-spec-selection | |
| path: /tmp/spec-selection.json | |
| if-no-files-found: ignore | |
| playwright: | |
| name: Playwright E2E tests | |
| needs: [changes, select-specs] | |
| # Skip-ownership stays with `changes`: gate on shouldRun only. select-specs is | |
| # skipped on full-run events, so `!cancelled()` is required for this job to run | |
| # past a skipped dependency; a skipped/failed select-specs yields an empty mode, | |
| # which the test step below treats as a full run. | |
| if: | | |
| !cancelled() | |
| && needs.changes.outputs.shouldRun == 'true' | |
| # 8-core depot: 18% avg / 30% peak memory observed on 16-core runs (#46853 noted | |
| # the workload "is not resource constrained" even after sharding was removed). | |
| # Talk to #team-devex before changing. | |
| runs-on: depot-ubuntu-24.04-8 | |
| timeout-minutes: 45 | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| outputs: | |
| vr_run_id: ${{ steps.vr-create.outputs.run_id }} | |
| # Wall-clock of the test step, for the CI-minutes-saved telemetry. | |
| test_seconds: ${{ steps.playwright-tests.outputs.test_seconds }} | |
| env: | |
| DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USER }} | |
| DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} | |
| INKEEP_API_KEY: ${{ secrets.INKEEP_API_KEY }} | |
| AZURE_INFERENCE_CREDENTIAL: ${{ secrets.AZURE_INFERENCE_CREDENTIAL }} | |
| AZURE_INFERENCE_ENDPOINT: ${{ secrets.AZURE_INFERENCE_ENDPOINT }} | |
| 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 }} | |
| clean: false | |
| # PR-controlled code runs in this job (pnpm installs, Playwright tests). Don't leave | |
| # GITHUB_TOKEN in .git/config where that code could read it — the report step | |
| # below receives the token explicitly via its GITHUB_TOKEN env instead. | |
| persist-credentials: false | |
| # Fail fast (seconds, before the stack boot) when the spec-selection | |
| # map has drifted: a target pointing at a renamed/deleted spec, or a new spec | |
| # no map entry reaches. Without this gate the selector silently falls open to | |
| # permanent full runs and the selection savings evaporate unnoticed. The | |
| # runtime selector stays fail-open; this is the merge-blocking counterpart | |
| # that forces the map fix onto the PR that caused the drift. Skipped on | |
| # unrebased branches that predate the selector (workflow edits hit every | |
| # open PR immediately; the tools files only arrive on rebase). | |
| - name: Validate spec-selection map | |
| shell: bash | |
| run: | | |
| if [ ! -f tools/test_playwright_spec_selection.py ]; then | |
| echo "spec-selection tools absent on this checkout (unrebased branch) — skipping" | |
| exit 0 | |
| fi | |
| python3 tools/test_playwright_spec_selection.py || { | |
| echo "::error file=tools/playwright_area_map.json::Spec-selection map is stale. Renamed/deleted a spec? Update the matching target in tools/playwright_area_map.json. Added a spec? Map it (products/scenes/explicit) or list it under full_suite_only. Run tools/test_playwright_spec_selection.py locally." | |
| exit 1 | |
| } | |
| - name: Clean up data directories with container permissions | |
| run: | | |
| # Use docker to clean up files created by containers | |
| [ -d "data" ] && docker run --rm -v "$(pwd)/data:/data" alpine sh -c "rm -rf /data/seaweedfs /data/minio" || true | |
| continue-on-error: true | |
| - name: Pull Docker images | |
| shell: bash | |
| run: | | |
| export CLICKHOUSE_SERVER_IMAGE=${{ needs.changes.outputs.oldest_supported }} | |
| cp posthog/user_scripts/latest_user_defined_function.xml docker/clickhouse/user_defined_function.xml | |
| docker compose -f docker-compose.dev.yml -f docker-compose.profiles.yml pull --quiet & | |
| - name: Add service hostnames to /etc/hosts | |
| shell: bash | |
| run: echo "127.0.0.1 db redis7 kafka clickhouse clickhouse-coordinator objectstorage seaweedfs temporal" | sudo tee -a /etc/hosts | |
| - 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 Python | |
| uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 | |
| with: | |
| python-version: 3.13.13 | |
| token: ${{ steps.setup-gh-token.outputs.token || github.token }} | |
| # Direct install: Tests run the explicit PR head, which may predate the local action. | |
| - name: Install uv | |
| id: setup-uv | |
| uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 | |
| with: | |
| version: '0.12.13' | |
| enable-cache: true | |
| cache-dependency-glob: uv.lock | |
| save-cache: ${{ github.ref == 'refs/heads/master' }} | |
| - name: Determine if hogql-parser has changed compared to master | |
| shell: bash | |
| id: hogql-parser-diff | |
| run: | | |
| git fetch --no-tags --prune --depth=1 origin master | |
| changed=$(git diff --quiet HEAD origin/master -- common/hogql_parser/ && echo "false" || echo "true") | |
| echo "changed=$changed" >> $GITHUB_OUTPUT | |
| - name: Install SAML (python3-saml) dependencies | |
| if: steps.setup-uv.outputs.cache-hit != 'true' | |
| shell: bash | |
| run: | | |
| sudo rm -f /etc/apt/sources.list.d/*twingate* && sudo apt-get update && sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl | |
| - 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: 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 }} | |
| # 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: Get pnpm store path | |
| id: pnpm-store | |
| shell: bash | |
| 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 }}- | |
| # Workaround for SIGABRT (exit 134) raised after all tests pass. | |
| # See https://pytest-qt.readthedocs.io/en/latest/troubleshooting.html#tlambert03-setup-qt-libs | |
| - uses: tlambert03/setup-qt-libs@19e4ef2d781d81f5f067182e228b54ec90d23b76 # v1.8 | |
| - name: Install plugin_transpiler | |
| shell: bash | |
| run: | | |
| pnpm --filter=@posthog/plugin-transpiler... install --frozen-lockfile | |
| bin/turbo --filter=@posthog/plugin-transpiler build | |
| - name: Install Python dependencies | |
| shell: bash | |
| run: | | |
| UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --frozen --dev | |
| - name: Install the working version of hogql-parser | |
| if: needs.changes.outputs.shouldRun == 'true' && steps.hogql-parser-diff.outputs.changed == 'true' | |
| shell: bash | |
| # This is not cached, because the current HEAD version of hogql-parser must be built if it has | |
| # changed (requirements.txt has the already-published version) | |
| run: | | |
| # Short budget: the `||` mirror is the real fallback for a host that is down. | |
| fetch=(curl --fail --location --retry 3 --retry-all-errors --retry-max-time 30 --connect-timeout 10) | |
| "${fetch[@]}" https://www.antlr.org/download/antlr4-cpp-runtime-4.13.1-source.zip --output antlr4-source.zip \ | |
| || "${fetch[@]}" https://raw.githubusercontent.com/antlr/website-antlr4/gh-pages/download/antlr4-cpp-runtime-4.13.1-source.zip --output antlr4-source.zip | |
| # Check that the downloaded archive is the expected runtime - a security measure | |
| anltr_known_md5sum="c875c148991aacd043f733827644a76f" | |
| antlr_found_ms5sum="$(md5sum antlr4-source.zip | cut -d' ' -f1)" | |
| if [[ "$anltr_known_md5sum" != "$antlr_found_ms5sum" ]]; then | |
| echo "Unexpected MD5 sum of antlr4-source.zip!" | |
| echo "Known: $anltr_known_md5sum" | |
| echo "Found: $antlr_found_ms5sum" | |
| exit 64 | |
| fi | |
| unzip antlr4-source.zip -d antlr4-source && cd antlr4-source | |
| cmake . | |
| DESTDIR=out make install | |
| sudo cp -r out/usr/local/include/antlr4-runtime /usr/include/ | |
| sudo cp out/usr/local/lib/libantlr4-runtime.so* /usr/lib/ | |
| sudo ldconfig | |
| cd .. | |
| pip install ./common/hogql_parser | |
| - name: Set up needed files | |
| shell: bash | |
| run: | | |
| mkdir -p frontend/dist | |
| touch frontend/dist/index.html | |
| touch frontend/dist/layout.html | |
| touch frontend/dist/exporter.html | |
| ./bin/download-mmdb | |
| - name: Install package.json dependencies with pnpm | |
| run: | | |
| pnpm --filter=@posthog/playwright... install --frozen-lockfile | |
| bin/turbo --filter=@posthog/frontend prepare | |
| - name: Log in to Docker Hub | |
| continue-on-error: true | |
| if: ${{ env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' }} | |
| uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 | |
| with: | |
| username: ${{ vars.DOCKERHUB_USER }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Resolve Rust capture container image tag | |
| id: capture-image | |
| run: | | |
| if [ -n "${{ github.event.pull_request.number }}" ]; then | |
| TAG="pr-${{ github.event.pull_request.number }}" | |
| else | |
| TAG="${{ github.sha }}" | |
| fi | |
| if docker manifest inspect "ghcr.io/posthog/posthog/capture:${TAG}" > /dev/null 2>&1; then | |
| echo "capture image found: ${TAG}" | |
| echo "POSTHOG_CAPTURE_TAG=${TAG}" >> "$GITHUB_ENV" | |
| else | |
| echo "capture image not found for ${TAG}, using master" | |
| echo "POSTHOG_CAPTURE_TAG=master" >> "$GITHUB_ENV" | |
| fi | |
| - name: Start Docker services | |
| env: | |
| COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml | |
| COMPOSE_PROFILES: capture,temporal | |
| run: bin/ci-wait-for-docker launch --down | |
| - name: Wait for Docker services | |
| env: | |
| COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml | |
| COMPOSE_PROFILES: capture,temporal | |
| run: bin/ci-wait-for-docker wait capture temporal | |
| - name: Build frontend | |
| run: | | |
| pnpm --filter=@posthog/frontend... install --frozen-lockfile | |
| pnpm --filter=@posthog/frontend build:products | |
| pnpm --filter=@posthog/frontend build | |
| - name: Collect static files without compression | |
| # The image-bitmap-data-url-worker-*.js.map files are already mirrored | |
| # into frontend/dist/ by copyRRWebWorkerFiles() in frontend/build.mjs | |
| # (see common/esbuilder/utils.mjs), which runs as part of the | |
| # `pnpm --filter=@posthog/frontend build` step above. No extra cp needed. | |
| env: | |
| STATIC_PRECOMPRESS: '0' | |
| run: python manage.py collectstatic --noinput | |
| - name: Create test database | |
| shell: bash | |
| run: | | |
| createdb posthog_e2e_test || echo "Database already exists" | |
| run_clickhouse_query() { | |
| local query="$1" | |
| for attempt in {1..10}; do | |
| if printf '%s' "$query" | curl --silent --show-error --fail 'http://localhost:8123/' --data-binary @-; then | |
| echo | |
| return 0 | |
| fi | |
| echo "ClickHouse query failed on attempt ${attempt}/10, retrying in 3s..." | |
| sleep 3 | |
| done | |
| echo "ClickHouse query failed after 10 attempts: $query" >&2 | |
| return 1 | |
| } | |
| # Drop and recreate clickhouse test database. The HTTP endpoint can briefly | |
| # reset connections while the container is still settling after startup. | |
| run_clickhouse_query 'SELECT 1' | |
| run_clickhouse_query 'DROP DATABASE IF EXISTS posthog_test SYNC' | |
| run_clickhouse_query 'CREATE DATABASE posthog_test' | |
| - name: Install sqlx-cli | |
| uses: ./.github/actions/setup-sqlx-cli | |
| - name: Restore schema cache from master | |
| # PRs and master pushes alike. Both keys are produced by ci-backend on | |
| # master push. Prefer the shared migration-set key (stable while master's | |
| # migration files are unchanged); fall back to the exact merge-base SHA key | |
| # (PRs only), then to the newest dump of any migration set — right after a | |
| # migration lands on master both exact keys miss until ci-backend re-dumps, | |
| # and the migrate step below tops a slightly stale dump up in seconds. | |
| # Cache miss falls through to a full migrate below. | |
| if: (github.event_name == 'pull_request' || github.event_name == 'push') && (needs.changes.outputs.schema_migrations_key != '' || needs.changes.outputs.schema_cache_key != '') | |
| uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 | |
| id: schema-cache | |
| with: | |
| path: schema.sql.gz | |
| key: ${{ needs.changes.outputs.schema_migrations_key || needs.changes.outputs.schema_cache_key }} | |
| restore-keys: | | |
| ${{ needs.changes.outputs.schema_cache_key }} | |
| ${{ needs.changes.outputs.schema_prefix_key }} | |
| - name: Prime posthog_e2e_test from cached schema | |
| # Schema dump is from master's `posthog` db but schemas are db-name agnostic. | |
| # Hogli's db:restore-schema-fresh DROPs+CREATEs the target db, restores the | |
| # schema, and runs ensure_migration_defaults to seed RunPython data that's | |
| # missing from a schema-only dump. The migrate step below still runs and | |
| # layers any newer migrations on top — the cache just skips the bulk of | |
| # the historical migration work. Treat cache/restore problems as misses: | |
| # restore-schema-fresh leaves a fresh empty db on failure, so the migrate | |
| # step below runs the full history. | |
| if: github.event_name == 'pull_request' || github.event_name == 'push' | |
| env: | |
| TARGET_DB: posthog_e2e_test | |
| run: | | |
| if [ ! -f schema.sql.gz ]; then | |
| echo "::notice::Schema cache miss — the migrate step below runs the full history" | |
| exit 0 | |
| fi | |
| mkdir -p .postgres-backups | |
| mv schema.sql.gz .postgres-backups/schema-latest.sql.gz | |
| if ! ./bin/hogli db:restore-schema-fresh; then | |
| echo "::warning::Schema restore failed (stale/incompatible cached dump?) — the migrate step below runs the full history" | |
| fi | |
| - name: Apply postgres and clickhouse migrations and setup dev | |
| run: | | |
| # Postgres migrations must run first — ClickHouse migration 0026 | |
| # depends on the posthog_instancesetting table in Postgres. | |
| # On cache hit this is near no-op; on cache miss it's a full migrate. | |
| # Either way, any PR-added migrations get applied here. | |
| python manage.py migrate --noinput | |
| # Run Rust migrations for persons e2e test database | |
| PERSONS_DATABASE_URL="postgres://posthog:posthog@localhost:5432/posthog_persons_e2e_test" | |
| sqlx database create -D "$PERSONS_DATABASE_URL" | |
| sqlx migrate run -D "$PERSONS_DATABASE_URL" --source rust/persons_migrations/ | |
| # Cyclotron node DB — the plugins container runs the CDP rerun worker | |
| # (default capabilities), which requires CYCLOTRON_NODE_DATABASE_URL to point | |
| # at a real, migrated database or it throws on startup and the server exits. | |
| CYCLOTRON_NODE_DATABASE_URL="postgres://posthog:posthog@localhost:5432/cyclotron_node" | |
| sqlx database create -D "$CYCLOTRON_NODE_DATABASE_URL" | |
| sqlx migrate run -D "$CYCLOTRON_NODE_DATABASE_URL" --source rust/cyclotron-node-migrations/ | |
| python manage.py migrate_clickhouse 2>&1 | |
| # Person/group reads go through personhog (no ORM fallback), so the replica | |
| # and router must be running before setup_dev creates demo persons. | |
| COMPOSE_FILE=docker-compose.dev.yml COMPOSE_PROFILES=ingestion bin/ci-wait-for-docker launch personhog-replica personhog-router | |
| python manage.py setup_dev | |
| - name: Source celery queues | |
| run: | | |
| source ./bin/celery-queues.env | |
| echo "CELERY_WORKER_QUEUES=$CELERY_WORKER_QUEUES" >> $GITHUB_ENV | |
| - name: Resolve Node.js container image tag | |
| id: node-image | |
| run: | | |
| # ci-nodejs-container.yml tags images as pr-<number> for PRs | |
| if [ -n "${{ github.event.pull_request.number }}" ]; then | |
| TAG="pr-${{ github.event.pull_request.number }}" | |
| else | |
| TAG="${{ github.sha }}" | |
| fi | |
| if docker manifest inspect "ghcr.io/posthog/posthog-node:${TAG}" > /dev/null 2>&1; then | |
| echo "posthog-node image found: ${TAG}" | |
| echo "POSTHOG_NODE_TAG=${TAG}" >> "$GITHUB_ENV" | |
| else | |
| echo "posthog-node image not found for ${TAG}, using master" | |
| echo "POSTHOG_NODE_TAG=master" >> "$GITHUB_ENV" | |
| fi | |
| - name: Start PostHog web, Celery worker, Temporal worker & ingestion | |
| run: | | |
| python manage.py run_autoreload_celery --type=worker &> /tmp/celery.log & | |
| python manage.py start_temporal_worker --task-queue analytics-platform-task-queue &> /tmp/temporal-worker.log & | |
| # WARNING: Worker count is tuned to avoid CPU scheduling contention. Talk to #team-devex before changing. | |
| python -m granian --interface asgi posthog.asgi:application --host 0.0.0.0 --port 8000 --log-level debug --workers 2 &> /tmp/server.log & | |
| # Start the Node.js containers now that the database exists and migrations have run. | |
| # plugins: CDP (no mode = default capabilities) | |
| # ingestion-general: event ingestion (ingestion-v2-combined mode) | |
| # ingestion-sessionreplay: session replay ingestion (recordings-blob-ingestion-v2 mode) | |
| # recording-api: session replay API (recording-api mode) | |
| # ingestion-error-tracking: error tracking ingestion (ingestion-errortracking mode) | |
| # ingestion-logs: logs ingestion (ingestion-logs mode) | |
| # ingestion-traces: traces ingestion (ingestion-traces mode) | |
| # capture-ai: AI event capture (/i/v0/ai/batch/, CAPTURE_MODE=ai); ingestion-general's | |
| # combined-mode AI consumer ingests the lane incl. multimodal blob offload | |
| COMPOSE_FILE=docker-compose.dev.yml COMPOSE_PROFILES=capture,ingestion bin/ci-wait-for-docker launch plugins ingestion-general ingestion-sessionreplay recording-api ingestion-error-tracking ingestion-logs ingestion-traces capture-ai | |
| # Install Playwright browsers while we wait for PostHog to be ready | |
| - name: Install Playwright browsers | |
| run: pnpm --filter=@posthog/playwright exec playwright install chromium --with-deps | |
| - name: Wait for PostHog to be ready | |
| uses: iFaxity/wait-on-action@1fe019e0475491e9e8c4f421b6914ccc3ed8f99c # v1.2.1 | |
| with: | |
| resource: http://localhost:8000 | |
| timeout: 180000 | |
| interval: 2000 | |
| verbose: true | |
| - name: Wait for node services to be ready | |
| env: | |
| COMPOSE_FILE: docker-compose.dev.yml | |
| COMPOSE_PROFILES: capture,ingestion | |
| run: bin/ci-wait-for-docker wait plugins ingestion-general ingestion-sessionreplay recording-api ingestion-error-tracking ingestion-logs ingestion-traces capture-ai | |
| - name: Clean snapshot directory | |
| run: find playwright/__snapshots__ -name '*.png' -delete 2>/dev/null || true | |
| - name: Run Playwright tests | |
| id: playwright-tests | |
| # continue-on-error so a failure doesn't fail the job here; 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: | |
| # Normal CI gets one retry as a temporary flake safety net. The nightly | |
| # audit overrides this to 0 so raw first-attempt failures remain visible. | |
| PLAYWRIGHT_RETRIES: ${{ inputs.playwright_retries || '1' }} | |
| # Empty when select-specs was skipped (full-run events) or chose a full run. | |
| SELECT_MODE: ${{ needs.select-specs.outputs.mode }} | |
| SELECT_SPECS: ${{ needs.select-specs.outputs.spec_files }} | |
| # WARNING: Worker count is tuned to avoid CPU scheduling contention. Talk to #team-devex before changing. | |
| # Reduced from 6+4 to 4+2 to minimize CPU scheduling contention (see PR #46853) | |
| # Capture-only: VR is the gate for visual changes, Playwright just captures screenshots | |
| run: | | |
| # Time the test step so the telemetry can turn "specs skipped" into | |
| # minutes. set +e around the run so a test failure still records the | |
| # duration; the original exit code is preserved for the quarantine gate. | |
| start=$(date +%s) | |
| set +e | |
| if [[ "$SELECT_MODE" == "selected" && -n "$SELECT_SPECS" ]]; then | |
| echo "Selective run: $SELECT_SPECS" | |
| # Word-splitting is intentional: SELECT_SPECS is a space-separated list | |
| # of controlled spec paths (from the area map + on-disk globs). | |
| # shellcheck disable=SC2086 | |
| pnpm --filter=@posthog/playwright exec playwright test $SELECT_SPECS --workers=4 --update-snapshots | |
| else | |
| echo "Full run (mode='${SELECT_MODE:-<skipped>}')" | |
| pnpm --filter=@posthog/playwright exec playwright test --workers=4 --update-snapshots | |
| fi | |
| rc=$? | |
| echo "test_seconds=$(( $(date +%s) - start ))" >> "$GITHUB_OUTPUT" | |
| exit "$rc" | |
| - name: Verify changed Playwright tests are stable | |
| if: steps.playwright-tests.outcome == 'success' && github.event_name == 'pull_request' | |
| shell: bash | |
| env: | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| # The script diffs from the merge-base of BASE_SHA and HEAD so a PR on an | |
| # older master doesn't inherit specs changed only on master. The merge-base | |
| # must be reachable from *both* tips: this job checks out HEAD at depth 1, so | |
| # deepen both histories (depth=1000 + blob:none mirrors the merge-base fetches | |
| # in the `changes` job). If the branch point is still out of reach, the script | |
| # warns and falls back to BASE_SHA — bounded by its own test-run budget guard. | |
| git fetch --no-tags --prune --depth=1000 --filter=blob:none origin "$BASE_SHA" | |
| git fetch --no-tags --depth=1000 --filter=blob:none origin "$HEAD_SHA" | |
| .github/scripts/verify-playwright-new-tests-and-snapshots.sh "$BASE_SHA" 10 | |
| # Visual Review: create run + upload snapshots directly from the test job. | |
| # Completion happens in handle-screenshots so the baseline lands in the same commit as PNGs. | |
| # Caches the built CLI, see vr-setup in ci-storybook.yml. The paths sit at the | |
| # workspace root here, so this is a separate entry. No restore-keys. | |
| - name: Restore VR CLI build | |
| id: restore-vr-cli | |
| if: always() && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push') | |
| uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 | |
| with: | |
| path: | | |
| products/visual_review/cli/dist | |
| products/visual_review/cli/node_modules | |
| key: vr-cli-build-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', 'products/visual_review/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 | |
| # in ci-storybook.yml. | |
| - name: Build VR CLI | |
| id: build-vr-cli | |
| if: always() && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push') && steps.restore-vr-cli.outputs.cache-hit != 'true' | |
| timeout-minutes: 8 | |
| shell: bash | |
| working-directory: 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, because the key is immutable once written. vr-complete reads | |
| # this entry, so it runs on the same runner provider. | |
| if: github.ref == 'refs/heads/master' && steps.restore-vr-cli.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 | |
| with: | |
| path: | | |
| products/visual_review/cli/dist | |
| products/visual_review/cli/node_modules | |
| key: ${{ steps.restore-vr-cli.outputs.cache-primary-key }} | |
| # A failed build must not reach this step, where it would only report a | |
| # missing module. A skipped build (cache hit) is not a failure. | |
| - name: Create VR run | |
| id: vr-create | |
| if: always() && steps.build-vr-cli.outcome != 'failure' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push') | |
| 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. | |
| VR_PURPOSE: ${{ github.event_name == 'push' && 'observe' || 'review' }} | |
| # Recorded on the run so the VR UI can re-trigger this job via the GitHub API. | |
| JOB_CHECK_RUN_ID: ${{ job.check_run_id }} | |
| # A selective run only regenerates a subset of snapshots. --partial tells VR | |
| # not to flag the un-run baseline snapshots as REMOVED and routes the run to a | |
| # non-gating status context. Empty on full runs and master pushes (the | |
| # default-branch fence forces is_partial=false there anyway). | |
| VR_PARTIAL: ${{ needs.select-specs.outputs.mode == 'selected' && '--partial' || '' }} | |
| run: | | |
| RUN_ID=$(node products/visual_review/cli/dist/cli/src/index.js run create \ | |
| --type playwright \ | |
| --baseline playwright/snapshots.yml \ | |
| --branch "$VR_BRANCH" \ | |
| --commit "$VR_COMMIT" \ | |
| --pr "$VR_PR" \ | |
| --purpose "$VR_PURPOSE" \ | |
| $VR_PARTIAL \ | |
| --token "$VR_TOKEN") | |
| echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT | |
| - name: Upload snapshots to Visual Review | |
| if: always() && steps.vr-create.outputs.run_id != '' | |
| env: | |
| VR_TOKEN: ${{ secrets.VR_API_TOKEN }} | |
| VR_RUN_ID: ${{ steps.vr-create.outputs.run_id }} | |
| run: | | |
| node products/visual_review/cli/dist/cli/src/index.js run upload \ | |
| --run-id "$VR_RUN_ID" \ | |
| --dir playwright/__snapshots__/ \ | |
| --baseline playwright/snapshots.yml \ | |
| --token "$VR_TOKEN" | |
| # ── Artifacts on failure / always ───────────────────────────────────── | |
| - name: Capture docker logs | |
| if: always() | |
| run: | | |
| mkdir -p playwright/test-results | |
| docker logs posthog-proxy-1 > playwright/test-results/docker-proxy.log 2>&1 || echo "No proxy container" > playwright/test-results/docker-proxy.log | |
| docker logs posthog-capture-1 > playwright/test-results/docker-capture.log 2>&1 || echo "No capture container" > playwright/test-results/docker-capture.log | |
| docker logs posthog-plugins-1 > playwright/test-results/docker-plugins.log 2>&1 || echo "No plugins container" > playwright/test-results/docker-plugins.log | |
| docker logs posthog-ingestion-general-1 > playwright/test-results/docker-ingestion-general.log 2>&1 || echo "No ingestion-general container" > playwright/test-results/docker-ingestion-general.log | |
| docker logs posthog-ingestion-sessionreplay-1 > playwright/test-results/docker-ingestion-sessionreplay.log 2>&1 || echo "No ingestion-sessionreplay container" > playwright/test-results/docker-ingestion-sessionreplay.log | |
| docker logs posthog-recording-api-1 > playwright/test-results/docker-recording-api.log 2>&1 || echo "No recording-api container" > playwright/test-results/docker-recording-api.log | |
| docker logs posthog-ingestion-error-tracking-1 > playwright/test-results/docker-ingestion-error-tracking.log 2>&1 || echo "No ingestion-error-tracking container" > playwright/test-results/docker-ingestion-error-tracking.log | |
| docker logs posthog-ingestion-logs-1 > playwright/test-results/docker-ingestion-logs.log 2>&1 || echo "No ingestion-logs container" > playwright/test-results/docker-ingestion-logs.log | |
| docker logs posthog-ingestion-traces-1 > playwright/test-results/docker-ingestion-traces.log 2>&1 || echo "No ingestion-traces container" > playwright/test-results/docker-ingestion-traces.log | |
| docker logs posthog-capture-ai-1 > playwright/test-results/docker-capture-ai.log 2>&1 || echo "No capture-ai container" > playwright/test-results/docker-capture-ai.log | |
| - name: Archive test artifacts | |
| if: always() | |
| uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 | |
| with: | |
| name: playwright-test-results | |
| path: | | |
| playwright/playwright-report/ | |
| playwright/playwright-report-attempt-*/ | |
| playwright/test-results/ | |
| playwright/test-results-attempt-*/ | |
| /tmp/celery.log | |
| /tmp/server.log | |
| /tmp/temporal-worker.log | |
| /tmp/playwright-output-attempt-*.log | |
| retention-days: 30 | |
| if-no-files-found: ignore | |
| - name: Upload test results | |
| if: always() | |
| uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 | |
| with: | |
| name: junit-results-playwright | |
| path: playwright/junit-results.xml | |
| if-no-files-found: ignore | |
| # Exports TRUNK_CODEOWNERS_PATH for the upload below, so Trunk attributes each test to | |
| # the team that owns its file. The guard is for an unrebased branch: this job checks out | |
| # the PR head ref, which may predate the script. Without it the uploader keeps reading | |
| # .github/CODEOWNERS, as it did before. | |
| - name: Build the Trunk ownership map | |
| if: ${{ !cancelled() && env.RUNS_ON_INTERNAL_PR == 'true' && github.repository == 'PostHog/posthog' && github.actor != 'dependabot[bot]' && vars.TRUNK_UPLOAD_ENABLED == 'true' }} | |
| shell: bash | |
| run: | | |
| script=.github/scripts/trunk-codeowners.sh | |
| if [ -x "$script" ]; then "$script" "$RUNNER_TEMP/trunk-codeowners" "playwright/junit-results.xml"; fi | |
| # Best-effort Trunk upload (continue-on-error); the "Fail on Playwright failure" step | |
| # below is the verdict, so a Trunk outage can't red a passing job. Internal PRs only | |
| # (needs the secret); cli-version pinned, not 'latest' (skips the release redirect). | |
| # 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). | |
| # Inlined rather than ./.github/actions/trunk-quarantine-gate, and so without its retry: | |
| # this job checks out the PR head ref, which cannot resolve a local action that only | |
| # exists on master until the branch rebases. | |
| - 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: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2 | |
| with: | |
| junit-paths: playwright/junit-results.xml | |
| org-slug: posthog-inc | |
| cli-version: '0.15.4' | |
| quarantine: true | |
| previous-step-outcome: ${{ steps.playwright-tests.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 Playwright failure | |
| if: ${{ !cancelled() && steps.playwright-tests.outcome == 'failure' && (vars.TRUNK_QUARANTINE_ENABLED != 'true' || steps.quarantine_gate.outcome != 'success') }} | |
| shell: bash | |
| run: exit 1 | |
| - name: Publish report to Cloudflare Pages | |
| if: always() && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) | |
| id: cf-deploy | |
| continue-on-error: true | |
| env: | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| run: | | |
| BRANCH="${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'master' }}" | |
| # Cloudflare Pages rejects any single file over 25 MiB, which fails the whole | |
| # deploy and leaves triagers with no report on a red run. Trace zips and videos | |
| # can cross that, so drop them from the copy we publish; the full attachments | |
| # stay in the uploaded playwright-test-results artifact. Prune at 24 MiB for margin. | |
| # Guarded and non-fatal so a missing/partial report dir still reaches the deploy. | |
| if [ -d playwright/playwright-report ]; then | |
| find playwright/playwright-report -type f -size +24M -printf 'Pruning oversized report file (%s bytes): %p\n' -delete || true | |
| fi | |
| npx --yes wrangler@3 pages deploy playwright/playwright-report \ | |
| --project-name=playwright-report \ | |
| --branch="$BRANCH" \ | |
| --commit-dirty=true 2>&1 | tee /tmp/wrangler-output.txt | |
| URL=$(grep -oP 'https://\S+\.pages\.dev' /tmp/wrangler-output.txt | tail -1 || true) | |
| if [ -n "$URL" ]; then | |
| echo "deployment-url=$URL" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Write report URL to job summary | |
| if: always() && steps.cf-deploy.outputs.deployment-url != '' | |
| env: | |
| DEPLOYMENT_URL: ${{ steps.cf-deploy.outputs.deployment-url }} | |
| run: | | |
| echo "## 🎭 Playwright report" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "**Report URL:** $DEPLOYMENT_URL" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY | |
| echo "- Run: [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY | |
| - name: Post Playwright section to CI report comment | |
| # Skipped on trunk-merge/** branches: merge-queue draft PRs are ephemeral and | |
| # unread, and each section write spends shared GITHUB_TOKEN budget. | |
| if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && !startsWith(github.head_ref, 'trunk-merge/') | |
| continue-on-error: true | |
| env: | |
| DEPLOYMENT_URL: ${{ steps.cf-deploy.outputs.deployment-url }} | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| if [ -f .github/scripts/post-playwright-section.mjs ]; then | |
| node .github/scripts/post-playwright-section.mjs | |
| node frontend/bin/ci-report/delete-legacy-comments.mjs | |
| else | |
| echo "Skipping Playwright section — script not present on this checkout" | |
| fi | |
| # hogli-lint: not-a-required-gate - records telemetry and emits no required check. | |
| capture-run-time: | |
| name: Capture run time | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 5 | |
| needs: [changes, select-specs, playwright, playwright_tests] | |
| 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.shouldRun == '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: 'Playwright tests pass' | |
| runner: 'depot' | |
| - 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: 'Playwright tests pass' | |
| runner: 'depot' | |
| # Spec-selection telemetry -> DevEx project (347861), where the CI-savings and | |
| # fallback-rate insights live. Emitted whenever the heavy job actually ran, so | |
| # full runs (this event's `mode=full`, `selection_eligible=false`) provide the | |
| # test-time baseline that selective runs are measured against. `selection_eligible` | |
| # is true on the ready internal-PR events where select-specs ran — the | |
| # fallback-rate denominator. Telemetry never reds CI (continue-on-error). | |
| - name: Capture spec selection to DevEx PostHog | |
| if: github.run_attempt == '1' && (needs.playwright.result == 'success' || needs.playwright.result == 'failure') | |
| 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-e2e-spec-selection' | |
| properties: | | |
| { | |
| "mode": ${{ toJSON(needs.select-specs.outputs.mode || 'full') }}, | |
| "selection_eligible": ${{ needs.select-specs.result == 'success' }}, | |
| "selected_count": ${{ needs.select-specs.outputs.selected_count || 'null' }}, | |
| "total_spec_count": ${{ needs.select-specs.outputs.total_spec_count || 'null' }}, | |
| "skipped_count": ${{ needs.select-specs.outputs.skipped_count || 'null' }}, | |
| "changed_file_count": ${{ needs.select-specs.outputs.changed_file_count || 'null' }}, | |
| "full_run_reason_category": ${{ toJSON(needs.select-specs.outputs.full_run_reason_category || '') }}, | |
| "full_run_reason_detail": ${{ toJSON(needs.select-specs.outputs.full_run_reason_detail || '') }}, | |
| "playwright_test_seconds": ${{ needs.playwright.outputs.test_seconds || 'null' }}, | |
| "playwright_result": ${{ toJSON(needs.playwright.result) }}, | |
| "runner_billing_multiplier": 4, | |
| "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) }} | |
| } | |
| vr-complete: | |
| name: Complete Visual Review run | |
| # Same runner provider as the playwright job, whose VR CLI cache entry this job | |
| # reads: GitHub-hosted and Depot runners keep separate cache stores. | |
| runs-on: depot-ubuntu-24.04 | |
| timeout-minutes: 15 | |
| needs: [playwright] | |
| # !cancelled() is load-bearing. select-specs is skipped on every full-run event (push to | |
| # master, PR opened/reopened/ready_for_review), and GitHub propagates a skip down the whole | |
| # needs chain — only a status-check function breaks the inheritance. The playwright job has | |
| # its own !cancelled() so it runs, but this job inherited the skip anyway, leaving the VR run | |
| # it created stuck pending and its commit status pending forever. The explicit result check | |
| # restores the implicit success() that !cancelled() drops. Mirrors ci-storybook.yml. | |
| if: | | |
| !cancelled() | |
| && needs.playwright.result == 'success' | |
| && needs.playwright.outputs.vr_run_id != '' | |
| 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 }} | |
| sparse-checkout: | | |
| .nvmrc | |
| products/visual_review/cli | |
| products/visual_review/frontend/generated/api.schemas.ts | |
| playwright/snapshots.yml | |
| 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 }} | |
| # Reads the entry the playwright job 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: | | |
| products/visual_review/cli/dist | |
| products/visual_review/cli/node_modules | |
| key: vr-cli-build-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', 'products/visual_review/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 | |
| # in ci-storybook.yml. | |
| - name: Build VR CLI | |
| if: steps.restore-vr-cli.outputs.cache-hit != 'true' | |
| timeout-minutes: 8 | |
| shell: bash | |
| working-directory: 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 | |
| # On master pushes the run was created with --purpose observe (tracking-only), but | |
| # `run complete` still exits 1 whenever visual changes are detected. Treat that as | |
| # informational on push; PRs still gate. Mirrors ci-storybook.yml. | |
| continue-on-error: ${{ github.event_name == 'push' }} | |
| env: | |
| VR_TOKEN: ${{ secrets.VR_API_TOKEN }} | |
| run: | | |
| node products/visual_review/cli/dist/cli/src/index.js run complete \ | |
| --run-id "${{ needs.playwright.outputs.vr_run_id }}" \ | |
| --baseline playwright/snapshots.yml \ | |
| --token "$VR_TOKEN" | |
| # Collate test + VR completion status for the required check | |
| playwright_tests: | |
| needs: [changes, playwright, vr-complete] | |
| name: Playwright tests pass | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 5 | |
| if: ${{ !cancelled() }} | |
| steps: | |
| - name: Check outcome | |
| run: | | |
| # Change detection gates the playwright job. A failure there skips it, and this | |
| # gate reads a skip as a pass, so test its result directly. select-specs is not | |
| # tested here: the playwright job runs past a failed selector and takes the full | |
| # suite, so its failure costs runner time but never coverage. | |
| if [[ "${{ needs.changes.result }}" != "success" && "${{ needs.changes.result }}" != "skipped" ]]; then | |
| echo "Change detection did not succeed (result: ${{ needs.changes.result }})." | |
| exit 1 | |
| fi | |
| if [[ "${{ needs.playwright.result }}" != "success" && "${{ needs.playwright.result }}" != "skipped" ]]; then | |
| echo "Playwright tests failed." | |
| 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 |