Skip to content

feat(metrics): expose query-metrics and metric-names-list MCP tools #312742

feat(metrics): expose query-metrics and metric-names-list MCP tools

feat(metrics): expose query-metrics and metric-names-list MCP tools #312742

Workflow file for this run

# This workflow runs all of our backend django tests.
#
# If these tests get too slow, look at increasing concurrency and re-timing the tests by manually dispatching
# .github/workflows/ci-backend-update-test-timing.yml action
name: Backend CI
on:
push:
branches:
- master
workflow_dispatch:
inputs:
clickhouseServerVersion:
description: ClickHouse server version. Leave blank for default
type: string
pull_request:
# Heavy test matrices (django, turbo-tests) skip drafts; ready_for_review
# re-triggers them when a PR leaves draft. To force them on a draft, add the
# `run-ci-backend` label — labeled/unlabeled re-trigger the run so the matrices
# start without needing a new push. Cheap checks still run on drafts.
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
merge_group:
concurrency:
# PRs: one active run per branch, cancel stale. Push: per-SHA so master
# pushes never cancel each other (check-migrations always completes).
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
SECRET_KEY: '6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da' # unsafe - for testing only
DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog'
REDIS_URL: 'redis://localhost'
CLICKHOUSE_HOST: 'localhost'
CLICKHOUSE_SECURE: 'False'
CLICKHOUSE_VERIFY: 'False'
# Restricted `autoresearch` user defined in docker/clickhouse/users-dev.xml,
# pointed at the CI test DB (posthog_test, not `default` as in regular dev).
CLICKHOUSE_TEST_CLUSTER_HOST: 'localhost'
CLICKHOUSE_TEST_CLUSTER_DATABASE: 'posthog_test'
CLICKHOUSE_TEST_CLUSTER_USER: 'autoresearch'
CLICKHOUSE_TEST_CLUSTER_PASSWORD: 'autoresearchpass'
CLICKHOUSE_TEST_CLUSTER_SECURE: 'False'
CLICKHOUSE_TEST_CLUSTER_VERIFY: 'False'
TEST: 1
CLICKHOUSE_SERVER_IMAGE_VERSION: ${{ github.event.inputs.clickhouseServerVersion || '' }}
CLICKHOUSE_COMPAT_PYTEST_TARGETS: 'posthog/clickhouse ee/clickhouse'
OBJECT_STORAGE_ENABLED: 'True'
OBJECT_STORAGE_ENDPOINT: 'http://localhost:19000'
OBJECT_STORAGE_ACCESS_KEY_ID: 'object_storage_root_user'
OBJECT_STORAGE_SECRET_ACCESS_KEY: 'object_storage_root_password'
UV_HTTP_TIMEOUT: 120
# tests would intermittently fail in GH actions
# with exit code 134 _after passing_ all tests
# this appears to fix it
# absolute wild tbh https://stackoverflow.com/a/75503402
DISPLAY: ':99.0'
# 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 }}
RUNS_ON_INTERNAL_PR: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
SANDBOX_JWT_PRIVATE_KEY: ${{ vars.OIDC_RSA_FAKE_PRIVATE_KEY }}
permissions:
contents: read
pull-requests: write
jobs:
# Job to decide if we should run backend ci
# See https://github.com/dorny/paths-filter#conditional-execution for more details
changes:
runs-on: depot-ubuntu-latest
timeout-minutes: 5
name: Determine need to run backend and migration checks
# Set job outputs to values from filter step
outputs:
backend: ${{ steps.filter.outputs.backend || 'true' }}
backend_files: ${{ steps.filter.outputs.backend_files }}
migrations: ${{ steps.filter.outputs.migrations || 'true' }}
migrations_files: ${{ steps.filter.outputs.migrations_files }}
persons_sql: ${{ steps.filter.outputs.persons_sql || 'true' }}
tasks_temporal: ${{ steps.filter.outputs.tasks_temporal || 'true' }}
openapi_types: ${{ steps.filter.outputs.openapi_types || 'true' }}
legacy: ${{ steps.filter.outputs.legacy || 'true' }}
schema: ${{ steps.filter.outputs.schema || 'true' }}
product_yamls: ${{ steps.filter.outputs.product_yamls || 'false' }}
product_yamls_files: ${{ steps.filter.outputs.product_yamls_files }}
timing_scripts: ${{ steps.filter.outputs.timing_scripts }}
# True only when every backend-relevant changed file is a data
# warehouse import source — lets build_django_matrix drop the
# Core/CorePOE/compat segments. Defaults false (full matrix).
data_import_sources_only: ${{ steps.sources.outputs.data_import_sources_only || 'false' }}
steps:
# For pull requests it's not necessary to checkout the code, but we
# also want this to run on master so we need to checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
clean: false
- 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
- 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: ${{ secrets.GH_APP_POSTHOG_PATHS_FILTER_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_PATHS_FILTER_PRIVATE_KEY }}
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
if: github.event_name != 'push' # Run all tests on master push
with:
token: ${{ steps.app-token.outputs.token || github.token }}
list-files: 'escape'
filters: |
backend:
# Avoid running backend tests for irrelevant changes
# NOTE: we are at risk of missing a dependency here. We could make
# the dependencies more clear if we separated the backend/frontend
# code completely
# really we should ignore ee/frontend/** but dorny doesn't support that
# - '!ee/frontend/**'
# including the negated rule appears to work
# but makes it always match because the checked file always isn't `ee/frontend/**` 🙈
- 'ee/**/*'
- 'common/__init__.py'
- 'common/hogql_parser/**'
- 'common/hogvm/**'
- 'common/ingestion/**'
- 'common/migration_utils/**'
- 'common/plugin_transpiler/**'
- 'posthog/**/*'
- 'products/**/backend/**/*'
# Python outside backend/ — products like logs and
# posthog_ai point `backend:test` at extra roots
# (skills/, scripts/). Catch any .py change so the
# backend job triggers when only those files move.
- 'products/**/*.py'
# Each product's package.json declares the per-product
# backend:test script that turbo-discover reads to
# compose the Product matrix. Without this entry,
# widening or fixing the script doesn't trigger
# tests it controls.
- 'products/*/package.json'
- bin/build-schema-latest-versions.py
- bin/build-taxonomy-json.py
- bin/check_uv_python_compatibility.py
- bin/find_python_dependencies.py
- bin/unit_metrics.py
- pyproject.toml
- uv.lock
- requirements.txt
- requirements-dev.txt
- mypy.ini
- pytest.ini
- conftest.py # Root conftest loads for every pytest run
- .test_durations # Used for pytest-split sharding
# generates posthog/schema.py
- frontend/src/queries/schema.json
# Operator parity check
- rust/feature-flags/src/properties/property_models.rs
- frontend/src/products.json # Loaded at runtime by posthog/products.py
- 'products/*/manifest.tsx' # Generates products.json
# hogli db commands used for test database setup
- 'tools/hogli-commands/hogli_commands/**'
- 'tools/hogli/**'
- hogli.yaml
# Make sure we run if someone is explicitly changing the workflow
- .github/workflows/ci-backend.yml
- .github/clickhouse-versions.json
# We use docker compose for tests, make sure we rerun on
# changes to docker-compose.dev.yml e.g. dependency
# version changes
- docker-compose.dev.yml
- docker-compose.profiles.yml
- docker-compose.base.yml
- bin/wait-for-docker
- bin/ci-wait-for-docker
- frontend/public/email/*
- 'docker/clickhouse/**'
legacy:
# Non-product backend code — when only products/ change,
# turbo-discover uses Turbo query affectedness to detect changed
# products and decide whether Django runs.
# Everything from backend: EXCEPT products/**/backend/**/*
- 'ee/**/*'
- 'common/__init__.py'
- 'common/hogql_parser/**'
- 'common/hogvm/**'
- 'common/ingestion/**'
- 'common/migration_utils/**'
- 'common/plugin_transpiler/**'
- 'posthog/**/*'
- bin/build-schema-latest-versions.py
- bin/build-taxonomy-json.py
- bin/check_uv_python_compatibility.py
- bin/find_python_dependencies.py
- bin/unit_metrics.py
- pyproject.toml
- uv.lock
- requirements.txt
- requirements-dev.txt
- mypy.ini
- pytest.ini
- conftest.py # Root conftest loads for every pytest run
- .test_durations
- frontend/src/products.json
- 'products/*/manifest.tsx'
- rust/feature-flags/src/properties/property_models.rs
- .github/workflows/ci-backend.yml
- .github/clickhouse-versions.json
- docker-compose.dev.yml
- docker-compose.profiles.yml
- docker-compose.base.yml
- bin/wait-for-docker
- bin/ci-wait-for-docker
- frontend/public/email/*
- 'docker/clickhouse/**'
schema:
# Tracked separately from `legacy` so turbo-discover can
# diff schema.json and narrow the product matrix to
# products that import the changed types.
- frontend/src/queries/schema.json
migrations:
- 'docker/clickhouse/**'
- 'posthog/migrations/*.py'
- 'products/*/backend/migrations/*.py'
- 'products/*/migrations/*.py' # Legacy structure
persons_sql:
- 'rust/persons_migrations/*.sql'
- 'rust/bin/migrate-persons'
timing_scripts:
- '.github/scripts/optimize_test_durations.py'
- '.github/scripts/turbo-discover.js'
tasks_temporal:
- 'products/tasks/backend/temporal/**/*'
product_yamls:
# Validates owner team slugs against PostHog/posthog collaborator teams.
# Costs one GitHub API call; gated here so we only pay it when ownership
# actually changes. Drives the validate-product-yamls job below.
- 'products/*/product.yaml'
openapi_types:
# Generated OpenAPI types - validate they match schema
- 'frontend/src/generated/**/*'
- 'products/*/frontend/generated/**/*'
- 'services/mcp/src/generated/**/*'
- 'services/mcp/src/api/generated.ts'
# Generation tooling - changes here could affect output
- 'tools/openapi-codegen/**/*'
- 'services/mcp/scripts/lib/**'
- 'frontend/bin/generate-openapi-types.mjs'
- 'frontend/src/lib/api-orval-mutator.ts'
- 'services/mcp/scripts/**/*'
# MCP tool config sources - feed generate-tools.ts
- 'services/mcp/definitions/**/*.yaml'
- 'services/mcp/definitions/prompts/**/*.md'
- 'products/*/mcp/**/*.yaml'
- 'products/*/mcp/prompts/**/*.md'
- 'services/mcp/schema/tool-definitions.json'
- 'services/mcp/schema/tool-definitions-v2.json'
- 'services/mcp/src/tools/generated/**/*'
- 'services/mcp/schema/generated-tool-definitions.json'
- 'services/mcp/schema/tool-definitions-all.json'
# Detect PRs whose backend changes are confined to data warehouse
# import sources that NO Django Core/CorePOE-collected code imports.
# Such a PR is exercised only by the Temporal segment (Core/CorePOE
# --ignore=posthog/temporal), so those segments add no coverage.
# The `coupled` sources are reverse-imported at runtime by Core tests
# (e.g. posthog/hogql/test/test_direct_postgres_query.py imports
# postgres) — a PR touching them must run the full matrix or it would
# silently skip those tests. `coupled` is kept complete by the guard
# test posthog/temporal/data_imports/sources/tests/test_ci_core_coupled_sources.py.
# Fails OPEN: empty/non-source/coupled file list → false → full matrix.
# PR events only; push and merge_group always run everything.
- name: Determine if only data warehouse sources changed
id: sources
if: github.event_name == 'pull_request'
env:
BACKEND_FILES: ${{ steps.filter.outputs.backend_files }}
run: |
prefix="posthog/temporal/data_imports/sources/"
# Space-separated, double-quoted, single line — parsed by the guard test.
coupled="common postgres salesforce stripe"
only_sources=false
if [ -n "$BACKEND_FILES" ]; then
only_sources=true
for f in $BACKEND_FILES; do
case "$f" in
"$prefix"*) ;;
*) only_sources=false; break ;;
esac
rest=${f#"$prefix"} # path within sources/
top=${rest%%/*} # vendor dir (or top-level filename)
case " $coupled " in
*" $top "*) only_sources=false; break ;;
esac
done
fi
echo "data_import_sources_only=$only_sources" >> "$GITHUB_OUTPUT"
echo "Only data warehouse import sources changed: $only_sources"
detect-snapshot-mode:
name: Detect snapshot mode
runs-on: depot-ubuntu-latest
timeout-minutes: 5
needs: [changes]
if: needs.changes.outputs.backend == 'true'
outputs:
mode: ${{ steps.detect.outputs.mode }}
steps:
- name: Detect mode
id: detect
env:
PR_REPO: ${{ github.event.pull_request.head.repo.full_name }}
REPO: ${{ github.repository }}
HAS_NO_SNAPSHOT_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'no-snapshot-update') }}
AUTHOR: ${{ github.actor }}
run: |
if [ "$PR_REPO" != "$REPO" ] && [ -n "$PR_REPO" ]; then
echo "mode=check" >> $GITHUB_OUTPUT
echo "Fork detected - running in CHECK mode (no commits allowed)"
elif [ "$HAS_NO_SNAPSHOT_LABEL" == "true" ]; then
echo "mode=check" >> $GITHUB_OUTPUT
echo "::notice::🔍 Running in CHECK mode - 'no-snapshot-update' label detected"
else
echo "Workflow triggered by: $AUTHOR"
# Dependabot is excluded - it creates new PRs that may need snapshot updates
# Other bots (github-actions, posthog-bot) commit snapshots and must use CHECK mode to avoid loops
if [[ "$AUTHOR" != "dependabot[bot]" ]] && \
([[ "$AUTHOR" == *"github-actions"* ]] || [[ "$AUTHOR" == *"[bot]"* ]] || [[ "$AUTHOR" == "posthog-bot" ]]); then
echo "mode=check" >> $GITHUB_OUTPUT
echo "::notice::🔍 Running in CHECK mode - snapshots must match exactly"
else
echo "mode=update" >> $GITHUB_OUTPUT
echo "::notice::🔄 Running in UPDATE mode - snapshots can be updated"
fi
fi
# Fast pre-job: determines which products need testing and if Django should run
# Only needs pnpm + node — no Python, Docker, or services
# Runs on depot to match the product-test runner environment
turbo-discover:
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: depot-ubuntu-latest
timeout-minutes: 20
name: Discover product tests
outputs:
run_legacy: ${{ steps.discover.outputs.run_legacy }}
matrix: ${{ steps.discover.outputs.matrix }}
schema_cache_key: ${{ steps.schema-key.outputs.key }}
django_shards: ${{ steps.discover.outputs.django_shards }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1000
filter: blob:none
- name: Fetch current PR base for Turbo affected diff
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: git fetch --no-tags --depth=1000 --filter=blob:none origin "$BASE_REF:refs/remotes/origin/$BASE_REF"
- name: Compute schema cache key from merge-base
id: schema-key
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
# HEAD is the synthetic merge commit; HEAD^2 is the PR branch tip.
# The fetch-depth:1000 checkout + base-ref fetch above ensure the
# full ancestry needed to find the divergence point is available.
MERGE_BASE=$(git merge-base HEAD^2 "origin/${BASE_REF}" 2>/dev/null || echo "")
if [ -n "$MERGE_BASE" ]; then
echo "key=posthog-schema-master-${MERGE_BASE}" >> $GITHUB_OUTPUT
else
echo "key=" >> $GITHUB_OUTPUT
echo "::notice::merge-base not found (branch too stale?) — schema cache will be skipped"
fi
- 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: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_PRIVATE_KEY }}
skip-token-revoke: true
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: .nvmrc
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
.github/workflows/ci-backend.yml
token: ${{ steps.setup-gh-token.outputs.token || github.token }}
- name: Install pnpm dependencies
run: pnpm install --frozen-lockfile --filter=@posthog/root
- name: Discover products to test
id: discover
env:
# On pushes to master, always run everything.
# On PRs, use the path filter to detect legacy changes.
LEGACY_CHANGED: ${{ github.event_name != 'pull_request' || needs.changes.outputs.legacy }}
SCHEMA_CHANGED: ${{ github.event_name != 'pull_request' || needs.changes.outputs.schema }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.event.pull_request.base.ref) || '' }}
TURBO_SCM_HEAD: ${{ github.sha }}
# Kill switch — drop comma-separated products from the matrix; empty = run all.
SKIP_PRODUCT_TESTS: ${{ vars.SKIP_PRODUCT_TESTS || '' }}
run: |
# turbo-discover.js uses Turbo's Git affectedness to detect
# changed products. Non-isolated product changes trigger the
# full suite (all products + Django).
RESULT=$(node .github/scripts/turbo-discover.js)
echo "Result: $RESULT"
echo "matrix=$(echo "$RESULT" | jq -c '.matrix')" >> $GITHUB_OUTPUT
echo "run_legacy=$(echo "$RESULT" | jq -r '.run_legacy')" >> $GITHUB_OUTPUT
echo "django_shards=$(echo "$RESULT" | jq -c '.django_shards // empty')" >> $GITHUB_OUTPUT
# Runs product tests in parallel — one matrix job per group
# Each job gets its own runner + Docker stack, so no shared DB conflicts
# Small products (< 50 tests) are grouped into a single job to avoid setup overhead
turbo-tests:
needs: [changes, turbo-discover, detect-snapshot-mode]
if: >-
always() &&
(github.event.pull_request.draft != true ||
contains(github.event.pull_request.labels.*.name, 'run-ci-backend')) &&
needs.turbo-discover.result == 'success' &&
needs.turbo-discover.outputs.matrix != '[]' &&
needs.turbo-discover.outputs.matrix != ''
runs-on: depot-ubuntu-latest
timeout-minutes: 40
name: Product tests (${{ matrix.group }})
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.turbo-discover.outputs.matrix) }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Start Docker early (before dependency installs) so containers can pull
# images and initialize while we install deps. This matches the pattern
# used by core-tests/django which achieves ~1s wait times.
- name: Clean up data directories
run: |
[ -d "data" ] && docker run --rm -v "$(pwd)/data:/data" alpine sh -c "rm -rf /data/seaweedfs /data/minio" || true
continue-on-error: true
- name: Log in to Docker Hub
if: ${{ env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Start services
env:
COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml
COMPOSE_PROFILES: temporal,azure
CLICKHOUSE_SERVER_IMAGE: clickhouse/clickhouse-server:26.3.10.60
run: |
cp posthog/user_scripts/latest_user_defined_function.xml docker/clickhouse/user_defined_function.xml
bin/ci-wait-for-docker launch --background --down \
db redis7 clickhouse zookeeper kafka objectstorage feature-flags \
temporal elasticsearch objectstorage-azure
- 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: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_PRIVATE_KEY }}
skip-token-revoke: true
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: .nvmrc
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
.github/workflows/ci-backend.yml
token: ${{ steps.setup-gh-token.outputs.token || github.token }}
- name: Install pnpm dependencies
run: pnpm install --frozen-lockfile --filter=@posthog/root
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: 3.13.13
token: ${{ secrets.POSTHOG_BOT_PAT }}
- name: Install uv
id: setup-uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
version: '0.11.14' # pinned: unpinned setup-uv calls GH API on every job, exhausts rate limit
enable-cache: true
cache-dependency-glob: uv.lock
save-cache: ${{ github.ref == 'refs/heads/master' }}
- name: Install SAML dependencies
if: steps.setup-uv.outputs.cache-hit != 'true'
run: sudo apt-get update && sudo apt-get install -y libxml2-dev libxmlsec1-dev libxmlsec1-openssl
- name: Install Rust
uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
with:
toolchain: 1.91.1
components: cargo
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
shared-key: 'v2-rust-backend'
workspaces: rust
save-if: ${{ github.ref == 'refs/heads/master' }}
- name: Install sqlx-cli
uses: ./.github/actions/setup-sqlx-cli
- name: Install Python dependencies
run: UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --frozen --dev
- name: Add service hostnames to /etc/hosts
run: echo "127.0.0.1 db redis7 kafka clickhouse clickhouse-coordinator objectstorage temporal" | sudo tee -a /etc/hosts
- name: Set up needed files
run: |
mkdir -p frontend/dist
touch frontend/dist/index.html frontend/dist/layout.html frontend/dist/exporter.html
./bin/download-mmdb
- name: Wait for Docker services
env:
COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml
COMPOSE_PROFILES: temporal,azure
CLICKHOUSE_SERVER_IMAGE: clickhouse/clickhouse-server:26.3.10.60
run: bin/ci-wait-for-docker wait
- name: Restore schema cache from master
if: ${{ github.event_name == 'pull_request' && needs.turbo-discover.outputs.schema_cache_key != '' }}
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: schema.sql.gz
key: ${{ needs.turbo-discover.outputs.schema_cache_key }}
- name: Prime test_posthog from cached schema
# No-op on cache miss; pytest --reuse-db falls back to a full migrate.
if: ${{ github.event_name == 'pull_request' }}
run: |
if [ ! -f schema.sql.gz ]; then
echo "::notice::Schema cache miss — pytest --reuse-db will run full migrations"
exit 0
fi
mkdir -p .postgres-backups
mv schema.sql.gz .postgres-backups/schema-latest.sql.gz
./bin/hogli db:restore-test-db
- name: Register Temporal search attributes
run: |
bin/wait-for-docker temporal
python manage.py register_temporal_search_attributes
- name: Run product tests
# --force: discover already decided this product needs testing, skip turbo cache
# --log-order=stream: stream pytest output live instead of buffering until completion
# pytest_args: optional pytest-split flags for sharded products (e.g. "-- --splits 3 --group 1")
env:
# --reuse-db: keep the test database between sequential product runs to avoid
# ClickHouse drop/create race conditions with ReplicatedMergeTree ZK metadata.
# On master, also collect timing data for pytest-split sharding.
PYTEST_ADDOPTS: >-
--reuse-db
${{ needs.detect-snapshot-mode.outputs.mode == 'update' && '--snapshot-update --snapshot-warn-unused' || '' }}
${{ github.ref == 'refs/heads/master' && '--store-durations --durations-path ../../.test_durations' || '' }}
run: pnpm turbo run backend:test ${{ matrix.filters }} --concurrency=1 --output-logs=full --force --log-order=stream ${{ matrix.pytest_args }}
- name: Upload timing data
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: ${{ github.ref == 'refs/heads/master' }}
with:
name: timing_data-Products-${{ strategy.job-index }}
path: .test_durations
include-hidden-files: true
retention-days: 2
- name: Verify new snapshots for flakiness
if: ${{ always() && needs.detect-snapshot-mode.outputs.mode == 'update' && github.event.pull_request.head.repo.full_name == 'PostHog/posthog' }}
shell: bash
run: |
.github/scripts/verify-new-snapshots.sh
- name: Generate snapshot patch
if: ${{ always() && needs.detect-snapshot-mode.outputs.mode == 'update' && github.event.pull_request.head.repo.full_name == 'PostHog/posthog' }}
shell: bash
run: |
mkdir -p /tmp/patches
git add -N '*.ambr' || true
if ! git diff --quiet '*.ambr' 2>/dev/null; then
git diff --binary --full-index '*.ambr' > /tmp/patches/backend-Products-${{ strategy.job-index }}.patch
echo "Generated patch with $(wc -l < /tmp/patches/backend-Products-${{ strategy.job-index }}.patch) lines"
else
echo "No snapshot changes to patch"
fi
- name: Upload snapshot patch
if: ${{ always() && needs.detect-snapshot-mode.outputs.mode == 'update' && github.event.pull_request.head.repo.full_name == 'PostHog/posthog' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: snapshot-patch-Products-${{ strategy.job-index }}
path: /tmp/patches/
if-no-files-found: ignore
retention-days: 1
# Lightweight repo-wide checks that only need Python + uv (no Docker/DB).
# Consolidates checks that previously each spun up their own runner.
repo-checks:
needs: [changes]
if: needs.changes.outputs.backend == 'true'
timeout-minutes: 10
name: Repo checks (depot-ubuntu-latest)
runs-on: depot-ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_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 }}
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
version: '0.11.14' # pinned: unpinned setup-uv calls GH API on every job, exhausts rate limit
- name: Install Python dependencies
run: UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --frozen --dev
- name: Bootstrap scaffold product
run: ./bin/hogli product:bootstrap spline_reticulator --non-interactive
- name: Lint product structure
run: ./bin/hogli product:lint --all
- name: Check version specifiers
run: python .github/scripts/check-version-specifiers.py
- name: Check IDOR model coverage
run: python .github/scripts/check-idor-model-coverage.py
- name: Check operator parity
run: python .github/scripts/check-operator-parity.py
- name: Check module boundaries (tach)
run: tach check --dependencies --interfaces
- name: Check product facade enforcement (import-linter)
run: lint-imports
# Validates product.yaml owners against PostHog/posthog collaborator teams.
# Gated on the product_yamls paths filter so we only spend a GitHub API call
# when ownership actually changes. The scoped form (positional product names)
# means a PR can't trip on pre-existing stale ownership in unrelated yamls.
# Skipped for fork PRs — app-token secrets aren't available there, and forks
# can't introduce new product.yaml entries that ship to master without a
# same-repo PR going through this check anyway.
validate-product-yamls:
needs: [changes]
if: >
needs.changes.outputs.product_yamls == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
name: Validate product.yaml owners (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Default GITHUB_TOKEN can't list /repos/{repo}/teams (needs org-level
# `members: read`). Reuse the assign-reviewers app — same secret, same
# use case. `owner: PostHog` scopes the issued token at the org so
# org-level permissions actually flow through; otherwise the token is
# repo-scoped and the org scope gets dropped.
- name: Get app token
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ secrets.GH_APP_POSTHOG_ASSIGN_REVIEWERS_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_ASSIGN_REVIEWERS_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: 3.13.13
# Reuse the app token already minted above instead of a second mint.
token: ${{ steps.app-token.outputs.token || github.token }}
- name: Install uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
with:
version: '0.11.14'
- name: Install Python dependencies
run: UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --frozen --dev
- name: Validate changed product.yaml owners
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
CHANGED_FILES: ${{ needs.changes.outputs.product_yamls_files }}
run: |
# CHANGED_FILES is space-separated (dorny list-files: escape).
# Each entry is products/<name>/product.yaml — strip to <name>.
names=$(echo "$CHANGED_FILES" | tr ' ' '\n' | sed -n 's|^products/\([^/]*\)/product.yaml$|\1|p' | xargs)
# Drop products whose product.yaml was deleted in this PR (e.g. the
# product moved out of products/) — there are no owners left to validate.
names=$(for n in $names; do [ -f "products/$n/product.yaml" ] && printf '%s ' "$n"; done | xargs)
if [ -z "$names" ]; then
echo "No existing product.yaml files in diff after filtering"
exit 0
fi
./bin/hogli product:lint:owners $names
# Migration validation.
# This job needs Docker + DB — it checks out master first to run baseline
# migrations, then checks out the PR branch. OpenAPI type generation belongs
# in check-openapi-types below.
check-migrations:
needs: [changes]
if: needs.changes.outputs.backend == 'true' || needs.changes.outputs.migrations == 'true' || needs.changes.outputs.persons_sql == 'true'
timeout-minutes: 20
# The "Publish Migration risk check" step posts the check via the
# posthog-tests app token, which carries its own checks:write
# installation permission — so the check lands in that app's own
# (otherwise-empty) check suite rather than floating under a random
# run in the crowded github-actions suite. The job's GITHUB_TOKEN
# therefore no longer needs checks:write. pull-requests:write is still
# required for the migration SQL comment steps below.
permissions:
contents: read
pull-requests: write
name: Validate migrations
runs-on: depot-ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
clean: false
- 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: Log in to Docker Hub
if: ${{ env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Stop/Start stack with Docker Compose
env:
COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml
run: |
bin/ci-wait-for-docker launch --background --down db redis7 clickhouse
- 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: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_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 }}
- name: Install uv
id: setup-uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
version: '0.11.14' # pinned: unpinned setup-uv calls GH API on every job, exhausts rate limit
enable-cache: true
cache-dependency-glob: uv.lock
save-cache: ${{ github.ref == 'refs/heads/master' }}
- name: Install SAML (python3-saml) dependencies
if: steps.setup-uv.outputs.cache-hit != 'true'
run: |
sudo apt-get update
sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl
- name: Install Rust
uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
with:
toolchain: 1.91.1
components: cargo
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
shared-key: 'v2-rust-backend'
workspaces: rust
save-if: ${{ github.ref == 'refs/heads/master' }}
- name: Install sqlx-cli
uses: ./.github/actions/setup-sqlx-cli
# First running migrations from master, to simulate the real-world scenario
- name: Checkout master
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
clean: false
- name: Install python dependencies for master
run: |
UV_PROJECT_ENVIRONMENT=.venv-master uv sync --frozen --dev
- name: Wait for Docker services
env:
COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml
run: bin/ci-wait-for-docker wait --only db redis7 clickhouse
- name: Run migrations up to master
run: |
# Run Django migrations first (excluding managed=False models)
.venv-master/bin/python manage.py migrate
# Then run persons migrations using sqlx; comment out until we've merged
# DATABASE_URL="postgres://posthog:posthog@localhost:5432/posthog_persons" \
# sqlx database create
# DATABASE_URL="postgres://posthog:posthog@localhost:5432/posthog_persons" \
# sqlx migrate run --source rust/persons_migrations/
# Now we can consider this PR's migrations
- name: Checkout this PR
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# For same-repo PRs, checkout the actual branch (not the merge commit)
# so origin/master..HEAD reflects PR files. Fork PRs fall back
# to the default merge commit.
ref: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'PostHog/posthog') && github.event.pull_request.head.ref || github.ref }}
clean: false
- name: Install python dependencies for this PR
run: |
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --frozen --dev
- name: Check migrations and post SQL comment
if: github.event_name == 'pull_request' && needs.changes.outputs.migrations == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CHANGED_FILES: ${{ needs.changes.outputs.migrations_files }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
# If no migration files changed, exit
if [ -z "$CHANGED_FILES" ]; then
echo "No migration files changed"
exit 0
fi
if [ -z "$BASE_SHA" ]; then
echo "::warning::BASE_SHA is empty — all changed migrations will be treated as new"
else
# Ensure the base commit is available for comparison
git fetch --no-tags --prune --depth=1 origin "$BASE_SHA" || echo "::warning::Could not fetch base SHA $BASE_SHA — all changed migrations will be shown as new"
fi
# Initialize comment body for SQL changes
COMMENT_BODY="## Migration SQL Changes\n\nHey 👋, we've detected some migrations on this PR. Here's the SQL output for each migration, make sure they make sense:\n\n"
HAS_NEW_MIGRATIONS=false
# Process each changed migration file (excluding Rust migrations)
for file in $CHANGED_FILES; do
# Skip Rust migrations as they're handled separately by sqlx
if [[ $file =~ rust/persons_migrations ]]; then
continue
fi
if [[ $file =~ migrations/([0-9]+)_ ]]; then
migration_number="${BASH_REMATCH[1]}"
# Get app name by looking at the directory structure
# For new structure products/user_interviews/backend/migrations, we want user_interviews
# For old structure products/user_interviews/migrations, we want user_interviews
if [[ $file =~ products/([^/]+)/backend/migrations/ ]]; then
app_name="${BASH_REMATCH[1]}"
else
app_name=$(echo $file | sed -E 's|^([^/]+/)*([^/]+)/migrations/.*|\2|')
fi
# Only show SQL for new migrations, not modifications to existing ones
if git cat-file -e "$BASE_SHA:$file" 2>/dev/null; then
echo "Skipping $file (already exists on base branch)"
continue
fi
HAS_NEW_MIGRATIONS=true
echo "Checking migration $migration_number for app $app_name"
# Get SQL output
SQL_OUTPUT=$(python manage.py sqlmigrate $app_name $migration_number)
# Add to comment body
COMMENT_BODY+="#### [\`$file\`](https:\/\/github.com\/${{ github.repository }}\/blob\/${{ github.sha }}\/$file)\n\`\`\`sql\n$SQL_OUTPUT\n\`\`\`\n\n"
fi
done
# Get existing comments (needed for both update and cleanup)
COMMENTS=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments")
# Extract comment ID if exists
SQL_COMMENT_ID=$(echo "$COMMENTS" | jq -r '.[] | select(.body | startswith("## Migration SQL Changes")) | .id' | head -1)
# If no new migrations, clean up any stale comment and exit
if [ "$HAS_NEW_MIGRATIONS" = false ]; then
echo "No new migrations to show (all changed files already exist on base branch)"
if [ -n "$SQL_COMMENT_ID" ]; then
echo "Deleting stale SQL comment $SQL_COMMENT_ID"
curl -X DELETE \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/issues/comments/$SQL_COMMENT_ID"
fi
exit 0
fi
# Add timestamp and commit SHA to SQL changes
TIMESTAMP=$(date -u '+%Y-%m-%d %H:%M UTC')
COMMIT_SHA="${{ github.event.pull_request.head.sha }}"
COMMIT_SHORT="${COMMIT_SHA:0:7}"
COMMENT_BODY+="\n*Last updated: $TIMESTAMP ([${COMMIT_SHORT}](https://github.com/${{ github.repository }}/commit/${COMMIT_SHA}))*"
# Convert \n into actual newlines
COMMENT_BODY=$(printf '%b' "$COMMENT_BODY")
COMMENT_BODY_JSON=$(jq -n --arg body "$COMMENT_BODY" '{body: $body}')
if [ -n "$SQL_COMMENT_ID" ]; then
# Update existing comment
echo "Updating existing SQL comment $SQL_COMMENT_ID"
curl -X PATCH \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/issues/comments/$SQL_COMMENT_ID" \
-d "$COMMENT_BODY_JSON"
else
# Post new SQL comment to PR
echo "Posting new SQL comment to PR"
curl -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
-d "$COMMENT_BODY_JSON"
fi
- name: Run migration risk analysis and post comment
if: github.event_name == 'pull_request'
# Step timeout: an analyzer hang fails THIS step (a failure, which
# !cancelled() still publishes as a definitive verdict) instead of
# running out the 20-min job timeout — a job timeout counts as a
# cancellation, which the publish step now skips, silently stranding
# the consumer. Normal analysis finishes in well under a minute.
timeout-minutes: 5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get risk analysis for all unapplied migrations (including third-party).
# --output-json gives downstream consumers (the check-run step
# below, and any other tool) a stable structured shape to read.
set +e # Don't exit immediately on error
RISK_ANALYSIS=$(python manage.py analyze_migration_risk --fail-on-blocked --output-json migration_analysis.json 2>/dev/null)
EXIT_CODE=$?
set -e # Re-enable exit on error
# Save analysis to file for artifact upload
if [ -n "$RISK_ANALYSIS" ]; then
echo "$RISK_ANALYSIS" > migration_analysis.md
fi
# Get existing comments
COMMENTS=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments")
# Extract comment ID if exists
COMMENT_ID=$(echo "$COMMENTS" | jq -r '.[] | select(.body | startswith("## 🔍 Migration Risk Analysis")) | .id' | head -1)
if [ -n "$RISK_ANALYSIS" ] && echo "$RISK_ANALYSIS" | grep -q "Summary:"; then
# Add timestamp and commit SHA to analysis
TIMESTAMP=$(date -u '+%Y-%m-%d %H:%M UTC')
COMMIT_SHA="${{ github.event.pull_request.head.sha }}"
COMMIT_SHORT="${COMMIT_SHA:0:7}"
RISK_COMMENT="## 🔍 Migration Risk Analysis\n\nWe've analyzed your migrations for potential risks.\n\n$RISK_ANALYSIS\n\n*Last updated: $TIMESTAMP ([${COMMIT_SHORT}](https://github.com/${{ github.repository }}/commit/${COMMIT_SHA}))*"
RISK_COMMENT=$(printf '%b' "$RISK_COMMENT")
RISK_COMMENT_JSON=$(jq -n --arg body "$RISK_COMMENT" '{body: $body}')
if [ -n "$COMMENT_ID" ]; then
# Update existing comment
echo "Updating existing risk analysis comment $COMMENT_ID"
curl -X PATCH \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/issues/comments/$COMMENT_ID" \
-d "$RISK_COMMENT_JSON"
else
# Create new comment if none exists
echo "Posting new risk analysis comment to PR"
curl -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
-d "$RISK_COMMENT_JSON"
fi
elif [ -n "$COMMENT_ID" ]; then
# No migrations to analyze but comment exists - delete it
echo "Deleting risk analysis comment (no migrations to analyze)"
curl -X DELETE \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/issues/comments/$COMMENT_ID"
else
echo "No migrations to analyze and no existing comment"
fi
# Fail the job if there were blocked migrations
if [ $EXIT_CODE -ne 0 ]; then
exit $EXIT_CODE
fi
- name: Get app token for Migration risk check
# Mint a posthog-tests installation token so the check is
# attributed to that app instead of github-actions[bot]. The
# app owns no workflow runs, so its check suite contains only
# this check — it shows up as a standalone, deterministically
# placed check rather than nesting under an arbitrary run in
# the crowded github-actions suite.
#
# Skipped on fork PRs: the app secrets aren't exposed to
# workflows triggered from forks, so the token can't be minted
# there. The analyzer still runs in earlier steps and fails the
# job on Blocked migrations; reviewers can read the verdict in
# the uploaded migration-analysis artifact.
#
# continue-on-error: publishing the check is auxiliary. A
# transient token-mint failure (GitHub API blip, secret
# rotation, app misconfig) must not redden check-migrations
# when migration validation itself passed. On failure the
# token output is empty and the publish step below is skipped.
#
# !cancelled(): a cancelled run was superseded by a newer one that
# publishes the verdict, so don't mint a token here. (${{ }} wraps
# the leading !, a YAML tag indicator.)
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
id: migration-risk-app-token
continue-on-error: true
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ secrets.GH_APP_POSTHOG_TESTS_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_TESTS_PRIVATE_KEY }}
- name: Publish Migration risk check
# Migration risk classification is published as a GitHub check on
# the head commit so it shows up in the PR UI alongside CI checks
# and so any tool that already reads check_runs (review bots,
# branch-protection rules, dashboards) can consume it without
# parsing the comment. The check is a CI feature; consumers and
# the analyzer are decoupled.
#
# Published via the posthog-tests app token (see the token step
# above) so the check lands in that app's own check suite.
#
# Published on success or genuine failure (including an analyzer
# crash, or zero migrations to analyze) so every PR ends up with a
# definitive verdict on its head SHA. Consumers can then treat
# "no completed check yet" purely as "CI hasn't finished" and
# don't need a fallback heuristic.
#
# !cancelled(): a cancelled job skipped the analyzer, leaving no
# migration_analysis.json, so an always() publish would stamp a
# spurious "❌ Analyzer failed". The newer run that superseded it
# publishes the real verdict; genuine crashes still publish here.
#
# Skipped on PRs from forks: the app token can't be minted there
# (secrets are withheld from fork-triggered workflows), so the
# check-runs POST would have no credential. The analyzer itself
# still runs in earlier steps and fails the job on Blocked
# migrations; reviewers can read the verdict in the uploaded
# migration-analysis artifact.
#
# Also skipped when the token step failed: its output is then
# empty and the check-runs POST would 401 and redden the job
# for a reason unrelated to migration validation.
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && steps.migration-risk-app-token.outputs.token != '' }}
env:
GITHUB_TOKEN: ${{ steps.migration-risk-app-token.outputs.token }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPO: ${{ github.repository }}
run: |
if [ ! -f migration_analysis.json ]; then
# Analyzer crashed before writing JSON. Publish a failure
# check so stamphog (or any consumer) gets a definitive
# verdict instead of looping in "wait for the check."
echo "No migration_analysis.json — analyzer didn't run; publishing failure check"
jq -n \
--arg name "Migration risk" \
--arg head_sha "$HEAD_SHA" \
'{name: $name, head_sha: $head_sha, status: "completed",
conclusion: "failure",
output: {title: "❌ Analyzer failed",
summary: "<!-- stamphog:v1 [] -->\nMigration analyzer did not produce migration_analysis.json. Re-run the Backend CI job; if it keeps failing, inspect the analyzer step logs."}}' \
| gh api "repos/$REPO/check-runs" --method POST --input -
exit 0
fi
MAX_LEVEL=$(jq -r '.max_level // "none"' migration_analysis.json)
case "$MAX_LEVEL" in
"Safe") CONCLUSION=success; TITLE="✅ All migrations safe" ;;
"Needs Review") CONCLUSION=neutral; TITLE="⚠️ Needs review" ;;
"Blocked") CONCLUSION=failure; TITLE="❌ Blocked migrations" ;;
"none"|"null")
# Zero Django migrations to analyze. Publish success so
# PRs that touch only ClickHouse/async/rbac migrations
# (which the analyzer doesn't cover) don't leave
# consumers waiting on a check that would never come.
CONCLUSION=success
TITLE="✅ No Django migrations to analyze"
;;
*)
echo "Unknown max_level '$MAX_LEVEL' — publishing failure"
CONCLUSION=failure
TITLE="❌ Unknown analyzer output"
;;
esac
# Embed the analyzed file paths in the summary as a hidden
# marker. Stamphog parses this to scope its deny-list bypass
# to exactly the files the analyzer classified — heuristics
# over directory names produce false bypasses for unrelated
# systems (ClickHouse, async migrations) that share the
# `migrations/` directory naming.
ANALYZED_PATHS=$(jq -c '[.migrations[].file_path | select(. != null)]' migration_analysis.json 2>/dev/null || echo '[]')
MARKER="<!-- stamphog:v1 ${ANALYZED_PATHS} -->"
# Truncate the human report to fit the check-run output
# limit (~64KB); the full markdown is also posted as a PR
# comment for humans. The marker stays at the top so it's
# never lost to truncation.
REPORT_BODY=$(head -c 59000 migration_analysis.md 2>/dev/null || echo "See PR comment")
SUMMARY=$(printf '%s\n%s' "$MARKER" "$REPORT_BODY")
jq -n \
--arg name "Migration risk" \
--arg head_sha "$HEAD_SHA" \
--arg conclusion "$CONCLUSION" \
--arg title "$TITLE" \
--arg summary "$SUMMARY" \
'{name: $name, head_sha: $head_sha, status: "completed",
conclusion: $conclusion,
output: {title: $title, summary: $summary}}' \
| gh api "repos/$REPO/check-runs" --method POST --input -
- name: Upload migration analysis artifact
if: always() && github.event_name == 'pull_request'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: migration-analysis
path: |
migration_analysis.md
migration_analysis.json
if-no-files-found: ignore
- name: Run migrations for this PR
run: |
# Run Django migrations first (excluding managed=False models)
python manage.py migrate
# Then run persons migrations using sqlx
DATABASE_URL="postgres://posthog:posthog@localhost:5432/posthog_persons" \
sqlx migrate run --source rust/persons_migrations/
- name: Dump migrated schema
if: github.event_name == 'push'
run: |
set -e
set -o pipefail
# Dump schema + django_migrations data so Django knows which migrations are applied
# Run pg_dump inside container to ensure version match (host has pg_dump 16, container has 15)
(docker compose -f docker-compose.dev.yml exec -T db pg_dump --schema-only --clean --if-exists -U posthog posthog && \
docker compose -f docker-compose.dev.yml exec -T db pg_dump --data-only --table=django_migrations -U posthog posthog) | gzip > schema.sql.gz
# Verify the dump is valid
gunzip -t schema.sql.gz
- name: Upload migrated schema artifact
if: github.event_name == 'push'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: migrated-schema
path: schema.sql.gz
retention-days: 90
- name: Save schema to Actions cache for PR shards
# Seeds a Postgres schema cache consumed by PR test jobs (turbo-tests,
# django, dagster). PR jobs restore using the merge-base SHA as the key
# so they always get a schema from their exact branch point rather than
# the newest master. LRU eviction handles cleanup.
if: github.event_name == 'push'
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: schema.sql.gz
key: posthog-schema-master-${{ github.sha }}
- name: Check migrations
# Skip migration safety check on master push (no migration_files from path filter)
if: github.event_name != 'push'
env:
MIGRATIONS_FILES: ${{ needs.changes.outputs.migrations_files }}
run: |
DATABASE_URL="postgres://posthog:posthog@localhost:5432/posthog_persons" \
sqlx migrate info --source rust/persons_migrations/
python manage.py makemigrations --check --dry-run
- name: Check CH migrations
run: |
# Same as above, except now for CH looking at files that were added in posthog/clickhouse/migrations/
git diff --name-status origin/master..HEAD | grep "A\sposthog/clickhouse/migrations/" | grep -v README | awk '{print $2}' | python manage.py test_ch_migrations_are_safe
- name: Render CH migration SQL per cloud environment
# CH migrations can build different `operations` lists at import time depending on
# `settings.CLOUD_DEPLOYMENT` (e.g. cloud-only Kafka tables). Render the SQL each
# environment will execute so reviewers can spot per-env divergence and verify gated
# branches don't sneak in ON CLUSTER or other forbidden patterns. We render once per
# environment and reuse the output both for the Actions log groups and the PR comment
# (environment -> node type(s) -> SQL). Environments that render identically are
# grouped together; if all environments match, the environment level is collapsed.
id: render_ch_sql
if: github.event_name == 'pull_request'
run: |
CHANGED=$(git diff --name-only --diff-filter=AM origin/master..HEAD | grep '^posthog/clickhouse/migrations/[0-9]' | grep -v __pycache__ || true)
if [ -z "$CHANGED" ]; then
echo "No ClickHouse migrations changed."
echo "has_changes=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "has_changes=true" >> "$GITHUB_OUTPUT"
{
echo '<!-- ch-migration-sql -->'
echo '## ClickHouse migration SQL per cloud environment'
echo ''
} > ch_migration_sql_comment.md
mkdir -p ch_sql_env
DEPLOYMENTS=('' US EU DEV)
LABELS=(unset US EU DEV)
# Bucket environments by identical rendered output (parallel indexed arrays;
# avoids bash 4 associative arrays) so divergent SQL prints once.
BUCKET_HASH=()
BUCKET_LABEL=()
BUCKET_FILE=()
for idx in "${!DEPLOYMENTS[@]}"; do
file="ch_sql_env/${idx}.md"
# Render once per environment; the same output feeds both the log group and the comment.
# On failure (e.g. an import error in the migration) write a visible marker rather than
# silently dropping the environment — stderr still shows in the step log for debugging.
if ! CLOUD_DEPLOYMENT="${DEPLOYMENTS[$idx]}" python manage.py print_ch_migration_sql --format markdown ${CHANGED} > "$file"; then
echo '- _⚠️ failed to render SQL for this environment — see the workflow logs_' > "$file"
fi
echo "::group::CLOUD_DEPLOYMENT=${LABELS[$idx]}"
cat "$file"
echo "::endgroup::"
h=$(sha1sum "$file" | cut -d' ' -f1)
found=-1
for b in "${!BUCKET_HASH[@]}"; do
if [ "${BUCKET_HASH[$b]}" = "$h" ]; then found=$b; break; fi
done
if [ "$found" -ge 0 ]; then
BUCKET_LABEL[$found]="${BUCKET_LABEL[$found]}, ${LABELS[$idx]}"
else
BUCKET_HASH+=("$h")
BUCKET_LABEL+=("${LABELS[$idx]}")
BUCKET_FILE+=("$file")
fi
done
if [ "${#BUCKET_HASH[@]}" -eq 1 ]; then
# Every environment renders the same SQL — collapse the environment level.
echo '_Identical across all cloud environments (unset, US, EU, DEV)._' >> ch_migration_sql_comment.md
echo '' >> ch_migration_sql_comment.md
cat "${BUCKET_FILE[0]}" >> ch_migration_sql_comment.md
else
for b in "${!BUCKET_HASH[@]}"; do
echo "- **${BUCKET_LABEL[$b]}**" >> ch_migration_sql_comment.md
sed 's/^/ /' "${BUCKET_FILE[$b]}" >> ch_migration_sql_comment.md
done
fi
- name: Post CH migration SQL PR comment
# Sticky comment: find the marker and update in place so pushes don't spam the PR.
# GITHUB_TOKEN is read-only for fork PRs, so only comment on same-repo PRs.
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
HAS_CHANGES: ${{ steps.render_ch_sql.outputs.has_changes }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const marker = '<!-- ch-migration-sql -->';
const hasChanges = process.env.HAS_CHANGES === 'true';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find((c) => c.body.includes(marker));
if (!hasChanges) {
// No migrations changed — clean up a stale sticky comment if one exists
// (e.g. migrations were added then removed in a later push), otherwise no-op.
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
return;
}
let body = fs.readFileSync('ch_migration_sql_comment.md', 'utf8');
const LIMIT = 65000; // GitHub caps comment bodies at 65536 chars
if (body.length > LIMIT) {
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
body = body.slice(0, LIMIT - 400) + `\n\n_…truncated. See the full SQL in the [workflow logs](${runUrl})._`;
}
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
check-openapi-types:
needs: [changes]
if: needs.changes.outputs.backend == 'true' || needs.changes.outputs.openapi_types == 'true'
timeout-minutes: 20
permissions:
contents: read
name: Validate OpenAPI types
runs-on: depot-ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# For same-repo PRs, checkout the actual branch (not the merge commit)
# so generated OpenAPI types can be committed directly. Fork PRs fall
# back to the default merge commit (auto-commit bails out for forks).
ref: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'PostHog/posthog') && github.event.pull_request.head.ref || github.ref }}
clean: false
- 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: Log in to Docker Hub
if: ${{ env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Stop/Start stack with Docker Compose
env:
COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml
run: |
bin/ci-wait-for-docker launch --background --down db clickhouse
- 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: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_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 }}
- name: Install uv
id: setup-uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
with:
version: '0.11.14' # pinned: unpinned setup-uv calls GH API on every job, exhausts rate limit
enable-cache: true
cache-dependency-glob: uv.lock
save-cache: ${{ github.ref == 'refs/heads/master' }}
- name: Install SAML (python3-saml) dependencies
if: steps.setup-uv.outputs.cache-hit != 'true'
run: |
sudo apt-get update
sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl
- name: Install python dependencies
run: |
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --frozen --dev
- name: Install pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: .nvmrc
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
.github/workflows/ci-backend.yml
token: ${{ steps.setup-gh-token.outputs.token || github.token }}
- name: Install package.json dependencies with pnpm
env:
npm_config_fetch_retries: 3
npm_config_fetch_retry_mintimeout: 10000
npm_config_fetch_retry_maxtimeout: 60000
run: pnpm --filter=@posthog/root --filter=@posthog/frontend... --filter=@posthog/mcp... install --frozen-lockfile
- name: Wait for Docker services
env:
COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml
run: bin/ci-wait-for-docker wait --only db clickhouse
- name: Add OpenAPI Problem Matcher
run: echo "::add-matcher::.github/openapi-problem-matcher.json"
- name: Check and update OpenAPI types
id: openapi-check
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BRANCH: ${{ github.event.pull_request.head.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if ! ./bin/hogli build:openapi; then
echo ""
echo "::error::OpenAPI schema generation failed."
echo ""
echo "If the failure above mentions \"enum naming encountered a non-optimally"
echo "resolvable collision\" or \"SchemaGenerationError: Failing as requested due"
echo "to warnings\", drf-spectacular found a problem in a serializer/viewset and"
echo "\`--fail-on-warn\` turned it into an error."
echo ""
echo "Diagnose locally:"
echo " python manage.py find_enum_collisions # prints a suggested override entry to add"
echo " hogli build:openapi-schema # to reproduce the full warning set"
echo ""
echo "Where to fix:"
echo " posthog/settings/web.py # ENUM_NAME_OVERRIDES (see comment block at the top)"
echo " /improving-drf-endpoints # invoke this skill for the full serializer/viewset guide"
echo ""
exit 1
fi
pnpm --filter=@posthog/mcp run scaffold-yaml -- --sync-all
if git diff --exit-code; then
echo "OpenAPI types are up to date"
echo "needs-commit=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "OpenAPI types are out of date"
# On non-PR builds or fork PRs, fail with instructions
if [ "$EVENT_NAME" != "pull_request" ] || \
[ "$HEAD_REPO" != "PostHog/posthog" ]; then
echo ""
echo "::error::OpenAPI types are out of date!"
echo ""
echo "The TypeScript API types in products/*/frontend/generated/ are auto-generated"
echo "from Django serializers and views. When you modify the backend API, you need"
echo "to regenerate these types."
echo ""
echo "To fix, run locally: hogli build:openapi"
echo "Then commit the updated generated files."
echo ""
echo "More info: https://posthog.com/handbook/engineering/type-system"
echo ""
echo "Questions? #team-devex on Slack"
exit 1
fi
echo "::notice::Committing updated OpenAPI types to PR branch"
# Verify branch hasn't advanced since CI started
CURRENT_SHA=$(git ls-remote origin "refs/heads/$BRANCH" | cut -f1)
if [ "$CURRENT_SHA" != "$HEAD_SHA" ]; then
echo "::error::Branch advanced during workflow ($HEAD_SHA -> $CURRENT_SHA) — cannot auto-commit OpenAPI types."
echo ""
echo "OpenAPI types are out of date and could not be auto-committed because the branch"
echo "was updated while this workflow was running. Please run locally and push:"
echo ""
echo " hogli build:openapi"
echo ""
echo "Then commit the updated generated files."
echo "needs-commit=false" >> $GITHUB_OUTPUT
exit 1
fi
echo "needs-commit=true" >> $GITHUB_OUTPUT
- name: Get app token for OpenAPI type commits
if: steps.openapi-check.outputs.needs-commit == 'true'
id: openapi-app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ secrets.GH_APP_POSTHOG_TESTS_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_TESTS_PRIVATE_KEY }}
- name: Disable auto-merge before OpenAPI type commit
if: steps.openapi-check.outputs.needs-commit == 'true'
env:
GH_TOKEN: ${{ steps.openapi-app-token.outputs.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr merge --disable-auto "$PR_NUMBER" || echo "Auto-merge was not enabled"
- name: Commit OpenAPI types via GitHub API (signed)
if: steps.openapi-check.outputs.needs-commit == 'true'
uses: planetscale/ghcommit-action@25309d8005ac7c3bcd61d3fe19b69e0fe47dbdde # v0.2.20
with:
commit_message: 'chore: update OpenAPI generated types'
repo: ${{ github.repository }}
branch: ${{ github.event.pull_request.head.ref }}
file_pattern: 'frontend/src/generated/** products/*/frontend/generated/** products/*/mcp/*.yaml services/mcp/definitions/*.yaml services/mcp/src/api/generated.ts services/mcp/src/generated/** services/mcp/schema/generated-tool-definitions.json services/mcp/schema/tool-definitions-all.json services/mcp/src/tools/generated/**'
env:
GITHUB_TOKEN: ${{ steps.openapi-app-token.outputs.token }}
build_django_matrix:
name: Build Django matrix
needs: [changes, get_clickhouse_versions, turbo-discover]
if: |
always() &&
needs.changes.outputs.backend == 'true' &&
needs.get_clickhouse_versions.result == 'success'
runs-on: depot-ubuntu-latest
timeout-minutes: 5
outputs:
include: ${{ steps.build.outputs.include }}
steps:
- name: Build matrix include list
id: build
env:
OLDEST_SUPPORTED_IMAGE: ${{ needs.get_clickhouse_versions.outputs.oldest_supported_image }}
COMPAT_MATRIX_JSON: ${{ needs.get_clickhouse_versions.outputs.compat_matrix }}
DJANGO_SHARDS_JSON: ${{ needs.turbo-discover.outputs.django_shards }}
DATA_IMPORT_SOURCES_ONLY: ${{ needs.changes.outputs.data_import_sources_only }}
run: |
# Auto-shard counts from turbo-discover (Amdahl's law on .test_durations).
# Falls back to hardcoded defaults if turbo-discover failed or data is missing.
CORE_SHARDS=38
CORE_POE_SHARDS=7
TEMPORAL_SHARDS=7
if [ -n "$DJANGO_SHARDS_JSON" ] && echo "$DJANGO_SHARDS_JSON" | jq -e '.Core.shards' > /dev/null 2>&1; then
CORE_SHARDS=$(echo "$DJANGO_SHARDS_JSON" | jq -r '.Core.shards')
CORE_POE_SHARDS=$(echo "$DJANGO_SHARDS_JSON" | jq -r '.CorePOE.shards')
TEMPORAL_SHARDS=$(echo "$DJANGO_SHARDS_JSON" | jq -r '.Temporal.shards')
echo "Auto-sharding: Core=$CORE_SHARDS, CorePOE=$CORE_POE_SHARDS, Temporal=$TEMPORAL_SHARDS"
else
echo "::warning::Django shard auto-sizing unavailable, using defaults: Core=$CORE_SHARDS, CorePOE=$CORE_POE_SHARDS, Temporal=$TEMPORAL_SHARDS"
fi
core=$(jq -cn --arg image "$OLDEST_SUPPORTED_IMAGE" --argjson shards "$CORE_SHARDS" '
[range(1; $shards + 1) | {
segment: "Core",
"person-on-events": false,
"python-version": "3.13.13",
"clickhouse-server-image": $image,
concurrency: $shards,
group: .,
artifact_key: ("core-" + (.|tostring)),
compat: false
}]
')
core_persons_on_events=$(jq -cn --arg image "$OLDEST_SUPPORTED_IMAGE" --argjson shards "$CORE_POE_SHARDS" '
[range(1; $shards + 1) | {
segment: "Core",
"person-on-events": true,
"python-version": "3.13.13",
"clickhouse-server-image": $image,
concurrency: $shards,
group: .,
artifact_key: ("core-poe-" + (.|tostring)),
compat: false
}]
')
temporal=$(jq -cn --arg image "$OLDEST_SUPPORTED_IMAGE" --argjson shards "$TEMPORAL_SHARDS" '
[range(1; $shards + 1) | {
segment: "Temporal",
"person-on-events": false,
"python-version": "3.13.13",
"clickhouse-server-image": $image,
concurrency: $shards,
group: .,
artifact_key: ("temporal-" + (.|tostring)),
compat: false
}]
')
compat_source="${COMPAT_MATRIX_JSON:-[]}"
compat=$(jq -cn --argjson compat "$compat_source" '
[
$compat
| to_entries[]
| .value + {
segment: "Core",
"person-on-events": false,
"python-version": "3.13.13",
compat: true,
artifact_key: ("compat-" + ((.key + 1)|tostring))
}
]
')
if [ "$DATA_IMPORT_SOURCES_ONLY" = "true" ]; then
# Only data warehouse import sources changed — the Temporal
# segment already covers posthog/temporal, so skip Core/CorePOE/compat.
echo "Only data warehouse import sources changed — running Temporal segment only"
include="$temporal"
else
include=$(jq -cn \
--argjson core "$core" \
--argjson core_persons_on_events "$core_persons_on_events" \
--argjson temporal "$temporal" \
--argjson compat "$compat" \
'$core + $core_persons_on_events + $temporal + $compat')
fi
echo "include=$include" >> "$GITHUB_OUTPUT"
echo "Django matrix size: $(jq -r 'length' <<< "$include")"
django:
needs: [changes, turbo-discover, detect-snapshot-mode, get_clickhouse_versions, build_django_matrix]
# Run legacy pytest if:
# 1. Legacy code directly changed (ee/, posthog/)
# 2. OR product changes affect legacy code (turbo-discover outputs run_legacy=true)
# 3. OR turbo-discover itself failed (conservative: run Django on detection failure)
if: |
always() &&
(github.event.pull_request.draft != true ||
contains(github.event.pull_request.labels.*.name, 'run-ci-backend')) &&
needs.changes.outputs.backend == 'true' &&
needs.build_django_matrix.result == 'success' &&
(needs.changes.outputs.legacy == 'true' ||
needs.turbo-discover.outputs.run_legacy == 'true' ||
(needs.turbo-discover.result != 'success' && needs.turbo-discover.result != 'skipped'))
# increase for tmate testing
# 45 for slow Temporal shards on master; keep <= deploy gate wait (container-images-cd.yml)
timeout-minutes: 45
name: Django tests – ${{ matrix.segment }}${{ matrix.compat && ' compat' || '' }} (persons-on-events ${{ matrix.person-on-events && 'on' || 'off' }}), Py ${{ matrix.python-version }}, ${{ matrix.clickhouse-server-image }} (${{matrix.group}}/${{ matrix.concurrency }})
# Runner type is performance-critical — consult #team-devex before changing
runs-on: depot-ubuntu-latest
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.build_django_matrix.outputs.include) }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
lfs: true
clean: false
# Put signal-fanout on PATH so the pytest steps below can use it as
# their `shell:` wrapper. GHA's `shell:` field doesn't allow
# ${{ github.workspace }} expansion, so a plain name on PATH is the
# only portable way to reference it.
- name: Install signal-fanout
shell: bash
run: sudo install -m 0755 .github/scripts/signal-fanout /usr/local/bin/signal-fanout
- 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: 'Safeguard: ensure no stray Python modules at product root'
run: |
echo "Checking that products/* only contain backend/, frontend/, or shared/ as Python code roots..."
BAD_FILES=$(find products -maxdepth 2 -type f -name "*.py" ! -path "*/backend/*" ! -name "__init__.py" ! -name "conftest.py" -o -maxdepth 2 -type d -name "migrations" ! -path "*/backend/*")
if [ -n "$BAD_FILES" ]; then
echo "❌ Found Python code or migrations outside backend/:"
echo "$BAD_FILES"
echo "Please move these into the appropriate backend/ folder."
exit 1
fi
echo "✅ No stray Python files or migrations found at product roots."
# Pre-tests
# Copies the fully versioned UDF xml file for use in CI testing
- name: Log in to Docker Hub
if: ${{ env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Stop/Start stack with Docker Compose
shell: bash
env:
COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml
COMPOSE_PROFILES: temporal,azure
CLICKHOUSE_SERVER_IMAGE: ${{ matrix.clickhouse-server-image || needs.get_clickhouse_versions.outputs.oldest_supported_image }}
WAIT_FOR_DOCKER_LAUNCH_RETRIES: 3
WAIT_FOR_DOCKER_LAUNCH_RETRY_DELAY: 5
run: |
cp posthog/user_scripts/latest_user_defined_function.xml docker/clickhouse/user_defined_function.xml
bin/ci-wait-for-docker launch --background --down-all-profiles
- 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: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_PRIVATE_KEY }}
skip-token-revoke: true
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
token: ${{ steps.setup-gh-token.outputs.token || github.token }}
- name: Install uv
id: setup-uv-tests
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
version: '0.11.14' # pinned: unpinned setup-uv calls GH API on every job, exhausts rate limit
enable-cache: true
cache-dependency-glob: uv.lock
save-cache: ${{ github.ref == 'refs/heads/master' }}
- name: Install SAML (python3-saml) dependencies
if: ${{ needs.changes.outputs.backend == 'true' && steps.setup-uv-tests.outputs.cache-hit != 'true' }}
shell: bash
run: |
sudo apt-get update && sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl
- name: Install Rust
if: needs.changes.outputs.backend == 'true'
uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
with:
toolchain: 1.91.1
components: cargo
- name: Cache Rust dependencies
if: needs.changes.outputs.backend == 'true'
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
shared-key: 'v2-rust-backend'
workspaces: rust
save-if: ${{ github.ref == 'refs/heads/master' }}
- name: Install sqlx-cli
if: needs.changes.outputs.backend == 'true'
uses: ./.github/actions/setup-sqlx-cli
- 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
# tests would intermittently fail in GH actions
# with exit code 134 _after passing_ all tests
# this appears to fix it
# absolute wild tbh https://stackoverflow.com/a/75503402
# Cache Qt library packages to reduce network dependency and flakiness
# Using cache-apt-pkgs-action instead of setup-qt-libs to avoid network issues
- name: Cache and install Qt libraries
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
with:
packages: libegl1 libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xinput0 libxcb-xfixes0 x11-utils libxcb-cursor0 libopengl0
version: 1.0
- 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.backend == 'true' && steps.hogql-parser-diff.outputs.changed == 'true' }}
shell: bash
# This is not cached currently, as it's important to build the current HEAD version of hogql-parser if it has
# changed (requirements.txt has the already-published version)
run: |
sudo apt-get install unzip cmake curl uuid pkg-config
curl --fail --location https://www.antlr.org/download/antlr4-cpp-runtime-4.13.1-source.zip --output antlr4-source.zip || curl --fail --location 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: Wait for Docker services
shell: bash
# Read-only health poll for core services only. Can't use --wait here
# because it blocks on ALL project containers including temporal, which
# boots slowly (auto-setup runs DB migrations). Temporal was started in
# background above and will be ready by the time temporal tests run.
env:
COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml
COMPOSE_PROFILES: temporal,azure
CLICKHOUSE_SERVER_IMAGE: ${{ matrix.clickhouse-server-image || needs.get_clickhouse_versions.outputs.oldest_supported_image }}
run: bin/ci-wait-for-docker wait
- name: Restore schema cache from master
if: ${{ github.event_name == 'pull_request' && needs.turbo-discover.outputs.schema_cache_key != '' }}
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: schema.sql.gz
key: ${{ needs.turbo-discover.outputs.schema_cache_key }}
- name: Prime test_posthog from cached schema
# No-op on cache miss; pytest --reuse-db falls back to a full migrate.
if: ${{ github.event_name == 'pull_request' }}
run: |
if [ ! -f schema.sql.gz ]; then
echo "::notice::Schema cache miss — pytest --reuse-db will run full migrations"
exit 0
fi
mkdir -p .postgres-backups
mv schema.sql.gz .postgres-backups/schema-latest.sql.gz
./bin/hogli db:restore-test-db
- name: Determine if --snapshot-update should be on
# UPDATE mode: human commits - update snapshots
# CHECK mode: bot commits (after snapshot update) - verify snapshots match exactly
# persons-on-events: always update (we ignore snapshot divergence there)
if: ${{ needs.changes.outputs.backend == 'true' && (needs.detect-snapshot-mode.outputs.mode == 'update' || matrix.person-on-events) }}
shell: bash
# --snapshot-warn-unused: pytest-split shards individual tests across
# runners, so each shard only exercises a subset of snapshots per file.
# Without this flag, --snapshot-update deletes "unused" snapshots that
# belong to other shards, causing cross-shard data loss.
run: echo "PYTEST_ARGS=--snapshot-update --snapshot-warn-unused" >> $GITHUB_ENV
- name: Add snapshot flags for compat subset runs
# Compat runs a subset and doesn't own snapshots (main Django rows do).
# Warn on unused snapshots to avoid version-specific false negatives.
if: ${{ needs.changes.outputs.backend == 'true' && matrix.compat }}
shell: bash
run: echo "PYTEST_ARGS=--snapshot-update --snapshot-warn-unused" >> $GITHUB_ENV
# Tests
- name: Log test environment diagnostics
if: ${{ needs.changes.outputs.backend == 'true' && matrix.segment == 'Core' }}
shell: bash
env:
SHARD_GROUP: ${{ matrix.group }}
SHARD_CONCURRENCY: ${{ matrix.concurrency }}
CLICKHOUSE_IMAGE: ${{ matrix.clickhouse-server-image }}
COMMIT_SHA: ${{ github.sha }}
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
run: |
echo "=== Test Environment Diagnostics ==="
echo "Shard: $SHARD_GROUP/$SHARD_CONCURRENCY"
echo "Python version: $(python --version)"
echo "ClickHouse version: $CLICKHOUSE_IMAGE"
echo "Commit: $COMMIT_SHA"
echo "Branch: $BRANCH_NAME"
echo "Runner: $(uname -a)"
echo "Memory: $(free -h | head -2)"
echo "Disk: $(df -h / | tail -1)"
echo "Docker containers:"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" || true
echo "==================================="
- name: Run Core tests
id: run-core-tests
if: ${{ needs.changes.outputs.backend == 'true' && matrix.segment == 'Core' }}
env:
PERSON_ON_EVENTS_V2_ENABLED: ${{ matrix.person-on-events && 'true' || 'false' }}
# Wrap bash with signal-fanout so the runner's cancel signal is
# propagated to the entire process tree (incl. pytest). Without
# this, GHA only signals the top-level bash and pytest survives
# until the runner's 10s SIGKILL — which sometimes misses
# detached descendants entirely. Installed earlier on PATH.
shell: 'signal-fanout bash --noprofile --norc -eo pipefail {0}'
run: | # async_migrations covered in ci-async-migrations.yml
set +e
pytest -v --tb=short --reuse-db -o junit_duration_report=call ${{
matrix.compat
&& env.CLICKHOUSE_COMPAT_PYTEST_TARGETS
|| (
matrix.person-on-events
&& './posthog/clickhouse/ ./posthog/queries/ ./products/product_analytics/backend/api/test/ ./posthog/api/test/dashboards/test_dashboard.py'
|| 'posthog'
)
}} ${{ matrix.compat && '' || (matrix.person-on-events && 'ee/clickhouse/' || 'ee/') }} -m "not async_migrations" \
--ignore=posthog/temporal \
--ignore=posthog/dags \
--ignore=common/hogvm/python/test \
${{ matrix.person-on-events && '--ignore=posthog/hogql_queries' || '' }} \
${{ matrix.person-on-events && '--ignore=posthog/hogql' || '' }} \
--splits ${{ matrix.concurrency }} --group ${{ matrix.group }} \
--durations=1000 --durations-min=1.0 --store-durations \
--pytest-durations=100 \
--splitting-algorithm=duration_based_chunks \
--reruns 2 --reruns-delay 1 \
-r fEsxX \
--junitxml=junit-core.xml \
$PYTEST_ARGS
exit_code=$?
set -e
if [ $exit_code -eq 5 ]; then
echo "No tests collected for this shard, this is expected when splitting tests"
exit 0
else
exit $exit_code
fi
# Uncomment this code to create an ssh-able console so you can debug issues with github actions
# (Consider changing the timeout in ci-backend.yml to have more time)
# - name: Setup tmate session
# if: failure()
# uses: mxschmitt/action-tmate@v3
- name: Log test environment diagnostics
if: ${{ needs.changes.outputs.backend == 'true' && matrix.segment == 'Temporal' }}
shell: bash
env:
SHARD_GROUP: ${{ matrix.group }}
SHARD_CONCURRENCY: ${{ matrix.concurrency }}
COMMIT_SHA: ${{ github.sha }}
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
run: |
echo "=== Test Environment Diagnostics ==="
echo "Shard: $SHARD_GROUP/$SHARD_CONCURRENCY"
echo "Python version: $(python --version)"
echo "Commit: $COMMIT_SHA"
echo "Branch: $BRANCH_NAME"
echo "Runner: $(uname -a)"
echo "Memory: $(free -h | head -2)"
echo "Disk: $(df -h / | tail -1)"
echo "Docker containers:"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" || true
echo "==================================="
- name: Run Temporal tests
id: run-temporal-tests
if: ${{ needs.changes.outputs.backend == 'true' && matrix.segment == 'Temporal' }}
# See "Run Core tests" for why signal-fanout wraps bash here.
shell: 'signal-fanout bash --noprofile --norc -eo pipefail {0}'
env:
AWS_S3_ALLOW_UNSAFE_RENAME: 'true'
MODAL_TOKEN_ID: ${{ needs.changes.outputs.tasks_temporal == 'true' && secrets.MODAL_TOKEN_ID || '' }}
MODAL_TOKEN_SECRET: ${{ needs.changes.outputs.tasks_temporal == 'true' && secrets.MODAL_TOKEN_SECRET || '' }}
run: |
set +e
# No per-test pytest --timeout; rely on the job-level timeout-minutes
# as the single safety net. The per-test guillotine added nothing
# over it (--timeout-method=thread os._exit()s the whole process
# anyway, so the shard dies either way) and was the reason the
# transaction=True overhead in async tests looked like "the
# timeout problem" instead of a per-test cost worth fixing.
pytest -v --tb=short --reuse-db -o junit_duration_report=call posthog/temporal products/batch_exports/backend/tests/temporal products/tasks/backend/temporal -m "not async_migrations" \
--splits ${{ matrix.concurrency }} --group ${{ matrix.group }} \
--durations=100 --durations-min=1.0 --store-durations \
--pytest-durations=100 \
--splitting-algorithm=duration_based_chunks \
--reruns 2 --reruns-delay 1 \
-r fEsxX \
--junitxml=junit-temporal.xml \
$PYTEST_ARGS
exit_code=$?
set -e
if [ $exit_code -eq 5 ]; then
echo "No tests collected for this shard, this is expected when splitting tests"
exit 0
else
exit $exit_code
fi
# Post tests
- name: Show docker compose logs on failure
if: failure() && (needs.changes.outputs.backend == 'true' && steps.run-core-tests.outcome != 'failure' && steps.run-temporal-tests.outcome != 'failure')
shell: bash
run: docker compose -f docker-compose.dev.yml logs
- name: Upload updated timing data as artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
# Upload on master, or on PRs that change timing/sharding scripts (for validation)
if: ${{ (github.ref == 'refs/heads/master' || needs.changes.outputs.timing_scripts == 'true') && needs.changes.outputs.backend == 'true' && !matrix.person-on-events && matrix.clickhouse-server-image == needs.get_clickhouse_versions.outputs.oldest_supported_image }}
with:
name: timing_data-${{ matrix.segment }}-${{ matrix.group }}
path: .test_durations
include-hidden-files: true
retention-days: 2
- name: Verify new snapshots for flakiness
# Only in UPDATE mode - CHECK mode doesn't update snapshots
if: ${{ needs.detect-snapshot-mode.outputs.mode == 'update' && github.event.pull_request.head.repo.full_name == 'PostHog/posthog' && needs.changes.outputs.backend == 'true' && !matrix.person-on-events && !matrix.compat }}
shell: bash
run: |
.github/scripts/verify-new-snapshots.sh
- name: Generate snapshot patch
# Only in UPDATE mode - CHECK mode verifies snapshots match exactly
if: ${{ needs.detect-snapshot-mode.outputs.mode == 'update' && github.event.pull_request.head.repo.full_name == 'PostHog/posthog' && needs.changes.outputs.backend == 'true' && !matrix.person-on-events && !matrix.compat }}
shell: bash
run: |
mkdir -p /tmp/patches
# Stage any new/modified .ambr files so they appear in git diff
git add -N '*.ambr' || true
# Generate patch if there are changes
if ! git diff --quiet '*.ambr' 2>/dev/null; then
git diff --binary --full-index '*.ambr' > /tmp/patches/backend-${{ matrix.segment }}-${{ matrix.group }}.patch
echo "Generated patch with $(wc -l < /tmp/patches/backend-${{ matrix.segment }}-${{ matrix.group }}.patch) lines"
else
echo "No snapshot changes to patch"
fi
- name: Upload snapshot patch
# Only in UPDATE mode
if: ${{ needs.detect-snapshot-mode.outputs.mode == 'update' && github.event.pull_request.head.repo.full_name == 'PostHog/posthog' && needs.changes.outputs.backend == 'true' && !matrix.person-on-events && !matrix.compat }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: snapshot-patch-${{ matrix.segment }}-${{ matrix.group }}
path: /tmp/patches/
if-no-files-found: ignore
retention-days: 1
- name: Archive email renders
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: needs.changes.outputs.backend == 'true' && matrix.segment == 'Core' && !matrix.person-on-events && !matrix.compat
with:
name: email_renders-${{ github.sha }}-${{ github.run_attempt }}-${{ matrix.segment }}-${{ matrix.person-on-events }}-${{ matrix.group }}
path: posthog/tasks/test/__emails__
retention-days: 1
- name: Upload test results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: junit-results-backend-${{ matrix.artifact_key }}
path: junit-*.xml
# Aggregate and commit snapshot changes from all matrix jobs
get_clickhouse_versions:
name: Get ClickHouse versions
needs: [changes]
if: needs.changes.outputs.backend == 'true'
runs-on: depot-ubuntu-latest
timeout-minutes: 5
outputs:
# Oldest supported version for main Django tests (JSON array for matrix)
oldest_supported: ${{ steps.read-versions.outputs.oldest_supported }}
# Oldest supported version as plain string (for comparisons)
oldest_supported_image: ${{ steps.read-versions.outputs.oldest_supported_image }}
# Fully expanded compat matrix (version x shard group)
compat_matrix: ${{ steps.read-versions.outputs.compat_matrix }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/clickhouse-versions.json
sparse-checkout-cone-mode: false
- name: Read ClickHouse versions from JSON
id: read-versions
run: |
compat_shards="2"
echo "Using $compat_shards shard(s) per compat version"
# Oldest supported version for main Django tests (for max compatibility, as JSON array for matrix)
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
echo "oldest_supported_image=$oldest_supported" >> $GITHUB_OUTPUT
echo "Oldest supported version for Django tests: $oldest_supported"
# Read all unique versions so we can derive compat coverage.
all=$(jq -c '[.[]] | unique' .github/clickhouse-versions.json)
if [ "$all" = "[]" ] || [ -z "$all" ]; then
echo "::error::No versions found in .github/clickhouse-versions.json"
exit 1
fi
echo "All CH versions found: $all"
# Compat coverage is only needed for non-oldest versions.
compat_versions=$(jq -c --arg oldest "$oldest_supported" '[.[]] | unique | map(select(. != $oldest))' .github/clickhouse-versions.json)
echo "Compat versions (excluding oldest): $compat_versions"
compat_count=$(jq -r 'length' <<< "$compat_versions")
echo "compat_count=$compat_count" >> $GITHUB_OUTPUT
if [ "$compat_count" -eq 0 ]; then
echo "compat_matrix=[]" >> $GITHUB_OUTPUT
echo "No non-oldest CH versions found — compat tests provide no additional coverage, skipping"
else
compat_matrix=$(jq -cn --argjson versions "$compat_versions" --argjson shards "$compat_shards" '
[
$versions[] as $version
| range(1; $shards + 1) as $group
| {
"clickhouse-server-image": $version,
"concurrency": $shards,
"group": $group
}
]
')
echo "compat_matrix=$compat_matrix" >> $GITHUB_OUTPUT
echo "Compat matrix: $compat_matrix"
fi
# Aggregate and commit snapshot changes from all matrix jobs
handle-snapshots:
name: Commit snapshot changes
needs: [changes, detect-snapshot-mode, django, turbo-tests]
# Only in UPDATE mode - CHECK mode verifies snapshots match exactly
# Run even if some matrix jobs failed (to commit snapshots from passing jobs)
if: ${{ always() && needs.detect-snapshot-mode.outputs.mode == 'update' && needs.changes.outputs.backend == 'true' && github.event.pull_request.head.repo.full_name == 'PostHog/posthog' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
pull-requests: write
steps:
# Use GitHub app token so Actions run after commiting updated snapshots
- name: Get app token
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ secrets.GH_APP_POSTHOG_TESTS_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_TESTS_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 1
- name: Download all snapshot patches
id: download-patches
continue-on-error: true
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
pattern: snapshot-patch-*
path: /tmp/snapshot-patches
merge-multiple: true
- name: Check for patches
id: check-patches
run: |
if [ "${{ steps.download-patches.outcome }}" == "failure" ] || [ ! -d /tmp/snapshot-patches ]; then
echo "has-patches=false" >> $GITHUB_OUTPUT
echo "No snapshot patches found"
exit 0
fi
if [ -n "$(find /tmp/snapshot-patches -name '*.patch' -type f -size +0c 2>/dev/null)" ]; then
echo "has-patches=true" >> $GITHUB_OUTPUT
echo "Found patches:"
ls -la /tmp/snapshot-patches/
else
echo "has-patches=false" >> $GITHUB_OUTPUT
echo "Patch files empty or missing - no snapshot changes"
fi
- name: Commit snapshots
if: steps.check-patches.outputs.has-patches == 'true'
uses: ./.github/actions/commit-snapshots
with:
workflow-type: backend
patch-path: /tmp/snapshot-patches
snapshot-path: '.'
commit-message: 'test(backend): update query snapshots'
pr-number: ${{ github.event.pull_request.number }}
repository: ${{ github.repository }}
commit-sha: ${{ github.event.pull_request.head.sha }}
branch-name: ${{ github.event.pull_request.head.ref }}
github-token: ${{ steps.app-token.outputs.token }}
# Job just to collate the status of the matrix jobs for requiring passing status
# Must depend on handle-snapshots to prevent auto-merge before commits complete
django_tests:
needs:
[
django,
check-migrations,
check-openapi-types,
async-migrations,
turbo-discover,
turbo-tests,
handle-snapshots,
repo-checks,
]
name: Django Tests Pass
runs-on: ubuntu-latest
timeout-minutes: 5
if: always()
steps:
- name: Summarize dependency results
run: |
{
echo "### Backend CI dependency results"
echo "| Dependency | Result |"
echo "| --- | --- |"
echo "| django | ${{ needs.django.result }} |"
echo "| check-migrations | ${{ needs.check-migrations.result }} |"
echo "| check-openapi-types | ${{ needs.check-openapi-types.result }} |"
echo "| async-migrations | ${{ needs.async-migrations.result }} |"
echo "| repo-checks | ${{ needs.repo-checks.result }} |"
echo "| turbo-discover | ${{ needs.turbo-discover.result }} |"
echo "| turbo-tests | ${{ needs.turbo-tests.result }} |"
echo "| handle-snapshots | ${{ needs.handle-snapshots.result }} |"
} | tee -a "$GITHUB_STEP_SUMMARY"
- name: Check dependency results
run: |
check_required_result() {
local label="$1"
local result="$2"
if [[ "$result" == "success" || "$result" == "skipped" ]]; then
return
fi
if [[ "$result" == "cancelled" ]]; then
echo "::error::$label was cancelled or timed out."
else
echo "::error::$label failed with result '$result'."
fi
exit 1
}
check_required_result "Django test matrix" "${{ needs.django.result }}"
check_required_result "Migration checks" "${{ needs.check-migrations.result }}"
check_required_result "OpenAPI type checks" "${{ needs.check-openapi-types.result }}"
check_required_result "Async migration tests" "${{ needs.async-migrations.result }}"
check_required_result "Repo checks" "${{ needs.repo-checks.result }}"
check_required_result "Turbo discover" "${{ needs.turbo-discover.result }}"
check_required_result "Product/Turbo tests" "${{ needs.turbo-tests.result }}"
# Check handle-snapshots result (OK if skipped, but fail if it failed)
if [[ "${{ needs.handle-snapshots.result }}" == "failure" ]]; then
echo "Snapshot commit job failed."
exit 1
fi
echo "All backend and product checks passed."
test-selection-verdict:
needs: [django, turbo-tests, changes]
name: Test selection verdict
if: always() && github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1000
filter: blob:none
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
version: '0.11.14'
- name: Download JUnit artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
pattern: junit-results-backend-*
path: /tmp/junit-results/
continue-on-error: true
- name: Run test selection and verdict
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
set -euo pipefail
mkdir -p /tmp/verdict
# Fetch the *current* tip of the base branch, not pull_request.base.sha:
# base.sha is captured at webhook time and goes stale if the branch
# later merges a newer master, which makes `base.sha...HEAD` balloon
# to include every merged-in master change.
git fetch --no-tags --depth=1000 --filter=blob:none origin "$BASE_REF:refs/remotes/origin/$BASE_REF"
uv run tools/snob_backend_test_selection_shadow.py \
--base-ref "origin/$BASE_REF" \
--pretty \
> /tmp/verdict/selection.json
uv run tools/test_selection_verdict.py \
/tmp/verdict/selection.json \
/tmp/junit-results/ \
--summary-path "$GITHUB_STEP_SUMMARY" \
--pretty \
> /tmp/verdict/verdict.json
- name: Upload verdict
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: test-selection-verdict-pr${{ github.event.pull_request.number }}
path: /tmp/verdict/
retention-days: 90
async-migrations:
name: Async migrations tests - ${{ matrix.clickhouse-server-image }} (depot-ubuntu-latest)
needs: [changes, turbo-discover, get_clickhouse_versions]
strategy:
fail-fast: false
matrix:
clickhouse-server-image: ${{ fromJson(needs.get_clickhouse_versions.outputs.oldest_supported) }}
# Run if legacy code changed, product changes affect legacy, or turbo-discover failed
if: |
always() &&
needs.changes.outputs.backend == 'true' &&
(needs.changes.outputs.legacy == 'true' ||
needs.turbo-discover.outputs.run_legacy == 'true' ||
(needs.turbo-discover.result != 'success' && needs.turbo-discover.result != 'skipped'))
runs-on: depot-ubuntu-latest
timeout-minutes: 30
steps:
- name: 'Checkout repo'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
clean: false
- 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: Log in to Docker Hub
if: ${{ env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Start stack with Docker Compose
env:
COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml
COMPOSE_PROFILES: temporal,azure
CLICKHOUSE_SERVER_IMAGE: ${{ matrix.clickhouse-server-image || needs.get_clickhouse_versions.outputs.oldest_supported_image }}
run: |
bin/ci-wait-for-docker launch --background --down
- 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: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_DEVEX_GENERAL_PRIVATE_KEY }}
skip-token-revoke: true
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: 'pyproject.toml'
token: ${{ steps.setup-gh-token.outputs.token || github.token }}
- name: Install uv
id: setup-uv-async
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
version: '0.11.14' # pinned: unpinned setup-uv calls GH API on every job, exhausts rate limit
enable-cache: true
cache-dependency-glob: uv.lock
save-cache: ${{ github.ref == 'refs/heads/master' }}
- name: Install SAML (python3-saml) dependencies
if: steps.setup-uv-async.outputs.cache-hit != 'true'
run: |
sudo apt-get update
sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl
- name: Install Rust
uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
with:
toolchain: 1.91.1
components: cargo
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
shared-key: 'v2-rust-backend'
workspaces: rust
save-if: ${{ github.ref == 'refs/heads/master' }}
- name: Install sqlx-cli
uses: ./.github/actions/setup-sqlx-cli
- name: Install python dependencies
shell: bash
run: |
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --frozen --dev
- name: Add service hostnames to /etc/hosts
run: sudo echo "127.0.0.1 db redis7 kafka clickhouse clickhouse-coordinator objectstorage seaweedfs temporal" | sudo tee -a /etc/hosts
- name: Set up needed files
run: |
mkdir -p frontend/dist
touch frontend/dist/index.html
touch frontend/dist/layout.html
touch frontend/dist/exporter.html
- name: Wait for Docker services
shell: bash
env:
COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml
COMPOSE_PROFILES: temporal,azure
CLICKHOUSE_SERVER_IMAGE: ${{ matrix.clickhouse-server-image || needs.get_clickhouse_versions.outputs.oldest_supported_image }}
run: bin/ci-wait-for-docker wait
- name: Restore schema cache from master
if: ${{ github.event_name == 'pull_request' && needs.turbo-discover.outputs.schema_cache_key != '' }}
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: schema.sql.gz
key: ${{ needs.turbo-discover.outputs.schema_cache_key }}
- name: Prime test_posthog from cached schema
# No-op on cache miss; pytest --reuse-db falls back to a full migrate.
if: ${{ github.event_name == 'pull_request' }}
run: |
if [ ! -f schema.sql.gz ]; then
echo "::notice::Schema cache miss — pytest --reuse-db will run full migrations"
exit 0
fi
mkdir -p .postgres-backups
mv schema.sql.gz .postgres-backups/schema-latest.sql.gz
./bin/hogli db:restore-test-db
- name: Run async migrations tests
run: |
# Scope async migration tests directly and reuse the primed test database.
pytest posthog/async_migrations/test -m "async_migrations" --reuse-db --reruns 2 --reruns-delay 1 --durations=100 --durations-min=1.0 --junitxml=junit.xml
- name: Upload test results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: junit-results-async-migrations
path: junit.xml
calculate-running-time:
name: Calculate running time
needs: [django_tests, async-migrations]
runs-on: ubuntu-latest
timeout-minutes: 5
if: # Run on pull requests to PostHog/posthog + on PostHog/posthog outside of PRs - but never on forks or Dependabot (no secrets access)
always() && github.actor != 'dependabot[bot]' && (
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'PostHog/posthog') ||
(github.event_name != 'pull_request' && github.repository == 'PostHog/posthog'))
steps:
- name: Get telemetry app token
id: telemetry-app-token
if: github.run_attempt == '1'
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ secrets.GH_APP_TELEMETRY_APP_ID }}
private-key: ${{ secrets.GH_APP_TELEMETRY_PRIVATE_KEY }}
- name: Capture running time to PostHog
if: github.run_attempt == '1'
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 }}
status-job: 'Django 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 }}
status-job: 'Django Tests Pass'
runner: 'depot'
report-test-timings:
name: Report per-test traces
needs: [django_tests]
runs-on: ubuntu-latest
continue-on-error: true
timeout-minutes: 10
# Master pushes always; PRs only when labelled `capture-test-timings`. Skip forks (no secrets access).
if: >-
always() &&
github.repository == 'PostHog/posthog' &&
(
(github.event_name != 'pull_request' && github.ref == 'refs/heads/master') ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == 'PostHog/posthog' &&
contains(github.event.pull_request.labels.*.name, 'capture-test-timings'))
)
steps:
# Labelled PRs run the trusted base parser, so the same PR can't swap the script that receives secrets.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
if: github.run_attempt == '1'
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }}
fetch-depth: 1
- name: Check for trusted timing reporter
id: timing-reporter
if: github.run_attempt == '1'
shell: bash
run: |
if [[ -f .github/scripts/report_test_timings.py ]]; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "available=false" >> "$GITHUB_OUTPUT"
echo "Trusted timing reporter is not available on the checked-out ref; skipping emit."
fi
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
if: github.run_attempt == '1' && steps.timing-reporter.outputs.available == 'true'
with:
version: '0.11.14' # pinned: unpinned setup-uv calls GH API on every job, exhausts rate limit
- name: Download junit artifacts
if: github.run_attempt == '1' && steps.timing-reporter.outputs.available == 'true'
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v6.0.0
with:
path: ./junit-artifacts
pattern: junit-results-*
- name: Emit per-test traces
if: github.run_attempt == '1' && steps.timing-reporter.outputs.available == 'true'
continue-on-error: true
env:
POSTHOG_DEVEX_PROJECT_API_TOKEN: ${{ secrets.POSTHOG_DEVEX_PROJECT_API_TOKEN }}
POSTHOG_OTLP_TRACES_ENDPOINT: ${{ vars.POSTHOG_OTLP_TRACES_ENDPOINT || 'https://us.i.posthog.com/i/v1/traces' }}
run: |
uv run --script .github/scripts/report_test_timings.py \
--min-duration-seconds=0.5 \
./junit-artifacts