Skip to content

feat(data-warehouse): implement demodesk import source #356031

feat(data-warehouse): implement demodesk import source

feat(data-warehouse): implement demodesk import source #356031

#
# 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