From 24eb20fe2de67419c4d8485cce1b723a984dc0c0 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Wed, 22 Jul 2026 14:53:40 -0400 Subject: [PATCH 1/3] chore(ci): run selective tests on all PRs, full suites in the merge queue Prepares CI for the Trunk merge queue. Regular PRs (draft or ready) now run the selective paths (snob Django subset, jest --findRelatedTests, narrowed Playwright specs, narrowed visual regression); the full suites run on the queue's trunk-merge/** draft PRs, master pushes, dispatch, and label-forced runs. Adds the impacted-targets upload workflow so PRs (including forks) can enter parallel-queue lanes. Co-Authored-By: Claude Fable 5 --- .../skills/authoring-ci-workflows/SKILL.md | 12 ++- .github/workflows/ci-backend.yml | 89 +++++++++--------- .github/workflows/ci-e2e-playwright.yml | 29 +++--- .github/workflows/ci-frontend.yml | 57 ++++++------ .github/workflows/ci-mcp.yml | 21 +++-- .github/workflows/ci-storybook.yml | 36 ++++---- .github/workflows/trunk-impacted-targets.yml | 91 +++++++++++++++++++ 7 files changed, 221 insertions(+), 114 deletions(-) create mode 100644 .github/workflows/trunk-impacted-targets.yml diff --git a/.agents/skills/authoring-ci-workflows/SKILL.md b/.agents/skills/authoring-ci-workflows/SKILL.md index 35b9e77f853c..b09e0f67d55d 100644 --- a/.agents/skills/authoring-ci-workflows/SKILL.md +++ b/.agents/skills/authoring-ci-workflows/SKILL.md @@ -16,7 +16,7 @@ The linters own the mechanical rules (below); this skill is the **judgment calls - Copy from a canonical file rather than from memory. `ci-paths-filter.yml` is the smallest complete example (triggers, concurrency, timeout, app token, Depot runner); - `ci-backend.yml` is the reference for the heavy patterns (bounded-depth checkout, per-SHA concurrency, draft/ready, sharding). + `ci-backend.yml` is the reference for the heavy patterns (bounded-depth checkout, per-SHA concurrency, selective PR runs vs the merge-queue full gate, sharding). - Related skills — reach for these instead of duplicating them here: - `/gating-production-deploys` — any job that pushes a prod image or dispatches a Charts deploy. - `/managing-github-actions-secrets` — creating the GitHub App / secret a workflow reads. @@ -175,11 +175,13 @@ PR-scoped cache writes nobody else can read just fragment the 10 GB LRU cap. New Depot labels must be added to the allow-list in `.github/actionlint.yaml` or actionlint fails. Details: `/depot-github-runners`. -## Draft vs ready-for-review +## Selective PR runs vs the merge-queue full gate -Most commits land before a PR is marked ready, and drafts can't merge — so heavy suites should run a narrowed subset on drafts and the full matrix on `ready_for_review` (the merge gate). -Add `ready_for_review` to the `pull_request` types, and make aggregator "... Tests Pass" jobs treat `skipped` as success so drafts still report. -Foot-gun: if a `select-tests` job is cancelled mid-flight, its `mode` output is empty — normalize empty-mode **on a draft** to `skip`, or the draft grabs the full matrix and serializes the ready run behind it. +The Trunk merge queue is the merge gate: it tests each queued PR on a `trunk-merge/**` draft PR that runs the full suites, and ejects the PR when they fail. +Regular PRs (draft or ready) therefore run a narrowed subset for fast feedback — heavy suites should gate their selective path on `!startsWith(github.head_ref, 'trunk-merge/')` and run the full matrix on trunk-merge/**PRs, master pushes, and dispatch. +Make aggregator "... Tests Pass" jobs treat `skipped` as success so selective runs still report, and keep a force label (`run-ci-backend` / `run-ci-frontend`) so a full run can be demanded on any PR. +Foot-gun: if a `select-tests` job is cancelled mid-flight, its `mode` output is empty — normalize empty-mode**on a regular PR\*\* to `skip`, or the PR grabs the full matrix and holds the per-branch concurrency slot for ~30 min. +Note the queue's test PRs are drafts — never use `pull_request.draft` to decide selective vs full; a draft check would give the merge gate the narrowed run. ## Backwards-compat with unrebased PRs diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml index 8afe1483c2f0..9dbb7600e5f2 100644 --- a/.github/workflows/ci-backend.yml +++ b/.github/workflows/ci-backend.yml @@ -13,16 +13,17 @@ on: description: ClickHouse server version. Leave blank for default type: string pull_request: - # Draft PRs run the snob-selected Django subset (≤3 shards) for fast feedback; - # turbo-tests (product tests) still skip drafts. Ready PRs run the full - # matrices — that full run is the merge gate, and ready_for_review re-triggers - # it when a PR leaves draft. To force the full matrices 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. + # PRs (draft or ready) run the snob-selected Django subset (≤3 shards) for + # fast feedback; turbo-tests (product tests) skip PRs. The full matrices run + # on the merge queue's trunk-merge/** draft PRs — that queue run is the merge + # gate, and the queue ejects the PR when it fails. To force the full matrices + # on a PR, 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 every PR. # The `test-new-events-schema` label opts a PR into the doubled matrices that # rerun everything with CLICKHOUSE_HOGQL_USE_NEW_EVENTS_SCHEMA=true; unlabeled # PRs run legacy-schema only (master runs always cover both). - types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + types: [opened, synchronize, reopened, labeled, unlabeled] concurrency: # PRs: one active run per branch, cancel stale. Push: per-SHA so master @@ -457,22 +458,22 @@ jobs: echo "run_legacy=$(echo "$RESULT" | jq -r '.run_legacy')" >> $GITHUB_OUTPUT echo "django_shards=$(echo "$RESULT" | jq -c '.django_shards // empty')" >> $GITHUB_OUTPUT - # Pick which Django tests to run on draft PRs. Drafts get the snob-selected - # subset for fast feedback; the full matrix runs once the PR is marked ready - # for review, and that ready run is the merge gate. When selection can't be - # trusted on a draft (legacy graph impact, turbo-discover or selector failure, - # a selector full-run signal), the draft skips the heavy matrices entirely — - # the pre-selection draft behavior — and defers to the ready full run. + # Pick which Django tests to run on PRs. PRs get the snob-selected subset for + # fast feedback; the full matrix runs on the merge queue's trunk-merge/** test + # PRs, and that queue run is the merge gate. When selection can't be trusted + # on a PR (legacy graph impact, turbo-discover or selector failure, a selector + # full-run signal), the PR skips the heavy matrices entirely and defers to the + # merge-queue full run. select-tests: name: Select tests needs: [changes, turbo-discover] - # Only draft PRs do selective runs; ready PRs and pushes always run full, - # which build_django_matrix falls back to + # Only regular PRs do selective runs; merge-queue trunk-merge/** PRs and + # pushes always run full, which build_django_matrix falls back to # when select-tests is skipped (empty MODE). The run-ci-backend label - # forces the full matrices on a draft. + # forces the full matrices on a PR. if: | github.event_name == 'pull_request' && - github.event.pull_request.draft == true && + !startsWith(github.head_ref, 'trunk-merge/') && !contains(github.event.pull_request.labels.*.name, 'run-ci-backend') && needs.changes.outputs.backend == 'true' runs-on: ubuntu-latest @@ -515,7 +516,7 @@ jobs: if [[ "$RUN_LEGACY" == "true" ]]; then # turbo-discover detected product->legacy graph impact; the # diff-based selector can't see this, so its subset would be - # incomplete. Skip the draft matrices; the ready run is full. + # incomplete. Skip the selective matrices; the merge-queue run is full. skip=true elif [[ "$TURBO_RESULT" != "success" && "$TURBO_RESULT" != "skipped" ]]; then # Conservative — turbo-discover failed. @@ -527,7 +528,7 @@ jobs: id: select # continue-on-error so a selector or git fetch failure doesn't fail this # job — a failed select-tests would leave MODE empty and build_django_matrix - # would fall back to an expensive full matrix on a draft. Classify reads + # would fall back to an expensive full matrix on a PR. Classify reads # steps.select.outcome (pre-continue-on-error) and emits mode=skip instead. continue-on-error: true if: steps.fallback.outputs.skip == 'false' @@ -571,8 +572,8 @@ jobs: } >> "$GITHUB_OUTPUT" } - # Untrusted selection on a draft skips the heavy matrices (the - # pre-selection draft behavior); the ready-for-review run is full. + # Untrusted selection skips the heavy matrices; the merge-queue + # run is full. fall_back_to_skip() { echo "mode=skip" >> "$GITHUB_OUTPUT" echo "core_files=" >> "$GITHUB_OUTPUT" @@ -589,14 +590,14 @@ jobs: fi if [[ "$SELECT_OUTCOME" != "success" ]] || [[ ! -s /tmp/selection.json ]]; then - echo "::warning::shadow selector did not produce output; draft skips heavy matrices (full run happens on ready for review)" + echo "::warning::shadow selector did not produce output; skipping heavy matrices (full run happens in the merge queue)" fall_back_to_skip selector_error exit 0 fi full_run_reasons=$(jq -r '.ast.full_run_reasons | length' /tmp/selection.json) if [[ "$full_run_reasons" -gt 0 ]]; then - echo "Selector requested a full run; draft defers it to ready for review:" + echo "Selector requested a full run; deferring it to the merge queue:" jq -r '.ast.full_run_reasons[]' /tmp/selection.json fall_back_to_skip full_run_requested exit 0 @@ -694,13 +695,16 @@ jobs: # 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 + # Skipped on regular PRs — the merge queue's trunk-merge/** run is the full gate; + # the run-ci-backend label forces them on a PR. # A status function is required to inspect dependency results after failures. # Use !cancelled(), not always(), so superseded matrix jobs still receive cancellation. turbo-tests: needs: [changes, turbo-discover, detect-snapshot-mode, build-product-test-matrix, get_clickhouse_versions] if: >- !cancelled() && - (github.event.pull_request.draft != true || + (github.event_name != 'pull_request' || + startsWith(github.head_ref, 'trunk-merge/') || contains(github.event.pull_request.labels.*.name, 'run-ci-backend')) && needs.turbo-discover.result == 'success' && needs.build-product-test-matrix.result == 'success' && @@ -2096,7 +2100,7 @@ jobs: RUN_POE: ${{ needs.select-tests.outputs.run_poe }} RUN_TEMPORAL: ${{ needs.select-tests.outputs.run_temporal }} CORE_FILES: ${{ needs.select-tests.outputs.core_files }} - IS_DRAFT: ${{ github.event.pull_request.draft }} + IS_SELECTIVE_PR: ${{ github.event_name == 'pull_request' && !startsWith(github.head_ref, 'trunk-merge/') }} FORCE_FULL: ${{ contains(github.event.pull_request.labels.*.name, 'run-ci-backend') }} # The new-events-schema variants double the matrix, so they only run on # PRs opted in via the test-new-events-schema label and on manual dispatch. @@ -2107,22 +2111,21 @@ jobs: # :NOTE: Keep shard counts/group ranges in sync with historical Django matrix tuning. # Consult #team-devex before changing. - # A draft PR must never fall back to the full matrix. select-tests only - # runs on drafts and sets MODE to "skip"/"selected"; an empty MODE on a - # draft means it was cancelled or failed (typically ready_for_review - # superseding it mid-flight). Without this, the draft builds the full - # matrix and holds the per-branch concurrency slot for ~30 min, so the - # ready-for-review run — the actual merge gate — queues behind it instead - # of starting. Skip here and defer to that ready run. The run-ci-backend - # label intentionally forces the full matrix on a draft, so honor it. - if [[ -z "$MODE" && "$IS_DRAFT" == "true" && "$FORCE_FULL" != "true" ]]; then - echo "::notice::Draft PR with no trusted Django selection — skipping heavy matrices; the ready-for-review run is the full gate" + # A regular PR must never fall back to the full matrix. select-tests + # only runs on regular PRs and sets MODE to "skip"/"selected"; an empty + # MODE on one means it was cancelled or failed (typically a newer push + # superseding it mid-flight). Without this, the PR builds the full + # matrix and holds the per-branch concurrency slot for ~30 min. Skip + # here and defer to the merge-queue run — the actual merge gate. The + # run-ci-backend label intentionally forces the full matrix, so honor it. + if [[ -z "$MODE" && "$IS_SELECTIVE_PR" == "true" && "$FORCE_FULL" != "true" ]]; then + echo "::notice::PR with no trusted Django selection — skipping heavy matrices; the merge-queue run is the full gate" MODE="skip" fi if [[ "$MODE" == "skip" ]]; then - # Draft PR where selection couldn't be trusted: skip the heavy - # matrices and defer to the full run on ready for review. + # PR where selection couldn't be trusted: skip the heavy + # matrices and defer to the full run in the merge queue. echo "include=[]" >> "$GITHUB_OUTPUT" echo "Django matrix size: 0 (mode=skip)" exit 0 @@ -2362,7 +2365,7 @@ jobs: # 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) - # The non-empty include guard covers drafts in skip mode and selected runs + # The non-empty include guard covers PRs in skip mode and selected runs # that chose zero files — an empty matrix would otherwise fail the job. # A status function is required to inspect dependency results after failures. # Use !cancelled(), not always(), so superseded matrix jobs still receive cancellation. @@ -3470,7 +3473,7 @@ jobs: needs: [select-tests] runs-on: ubuntu-latest timeout-minutes: 5 - # select-tests only runs on internal draft PRs; fire only when it actually + # select-tests only runs on internal PRs outside the merge queue; fire only when it actually # produced a decision. DevEx project only, mirroring monitor-github-rate-limit; # continue-on-error so telemetry never reds CI. Skipped on forks/Dependabot, # which never have the secret (and where select-tests didn't run anyway). @@ -3574,15 +3577,17 @@ jobs: pull-requests: write # sticky CI report comment # In-repo PRs only — fork PRs get a read-only token that can't comment. Runs when either # the product (turbo-tests) or core (django_tests) suite ran, so a core-only PR still - # gets a comment; both skip on drafts, leaving nothing to report. - # drafts run a partial test selection — patch coverage would report false gaps + # gets a comment. + # Only label-forced full runs get a comment: regular PRs run a partial test + # selection — patch coverage would report false gaps — and the merge queue's + # trunk-merge/** PRs are ephemeral, so a comment there is never read. # Security invariant: this job runs PR-head-controlled scripts (coverage_report.py, # post-coverage-section.mjs) — never add secrets beyond github.token to this job # (report-test-timings checks out the base ref for exactly this reason). if: >- !cancelled() && github.event_name == 'pull_request' && - github.event.pull_request.draft == false && + contains(github.event.pull_request.labels.*.name, 'run-ci-backend') && github.event.pull_request.head.repo.full_name == 'PostHog/posthog' && (needs.turbo-tests.result != 'skipped' || needs.django_tests.result != 'skipped') steps: diff --git a/.github/workflows/ci-e2e-playwright.yml b/.github/workflows/ci-e2e-playwright.yml index 008bc9323a83..dcf28b099037 100644 --- a/.github/workflows/ci-e2e-playwright.yml +++ b/.github/workflows/ci-e2e-playwright.yml @@ -8,8 +8,9 @@ on: pull_request: # Draft PRs skip E2E entirely — the broad path filter below matches nearly # every code PR, and a full ~25-minute stack boot per draft push is the - # remaining big per-PR cost. The ready-for-review full run is the merge - # gate; use workflow_dispatch to force a run on a draft. + # remaining big per-PR cost. Ready PRs run a narrowed spec selection; the + # full suite (the merge gate) runs on the merge queue's trunk-merge/** + # draft PRs. Use workflow_dispatch to force a run on a draft. types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: inputs: @@ -76,14 +77,16 @@ jobs: # keep ample headroom. A timeout here skips Playwright, which the aggregator # would count as a pass. timeout-minutes: 7 - # Run on master push, manual dispatch, and on ready internal-repo PRs. - # Drafts skip E2E (see the trigger comment); the `Playwright tests pass` - # aggregator treats skipped as success, and drafts can't merge anyway. + # Run on master push, manual dispatch, ready internal-repo PRs, and the + # merge queue's trunk-merge/** PRs (drafts, but the full merge gate). + # Other drafts skip E2E (see the trigger comment); the `Playwright tests + # pass` aggregator treats skipped as success, and drafts can't merge anyway. if: | github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.draft != true) + (github.event.pull_request.draft != true || + startsWith(github.head_ref, 'trunk-merge/'))) name: Determine need to run E2E checks outputs: # Debounce can veto a PR run whose SHA has already been superseded (see below). @@ -305,17 +308,17 @@ jobs: select-specs: name: Select affected E2E specs needs: [changes] - # Narrow the suite only on incremental `synchronize` pushes to a ready - # (non-draft) internal PR. Every other trigger — opened, reopened, - # ready_for_review, workflow_dispatch, and push to master — skips - # this job, leaving empty outputs so the playwright job runs the FULL suite. - # The merge gate (ready_for_review run) and the post-merge master run always - # run everything, so a selection miss is caught there. Fail-open is FULL, + # Narrow the suite on every run for a ready (non-draft) internal PR + # outside the merge queue. The merge queue's trunk-merge/** runs, + # workflow_dispatch, and pushes to master skip this job, leaving empty + # outputs so the playwright job runs the FULL suite. The merge gate + # (trunk-merge/** run) and the post-merge master run always run + # everything, so a selection miss is caught there. Fail-open is FULL, # never skip: over-selection would silently drop coverage. if: | needs.changes.outputs.shouldRun == 'true' && github.event_name == 'pull_request' - && github.event.action == 'synchronize' + && !startsWith(github.head_ref, 'trunk-merge/') && github.event.pull_request.draft != true && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml index b8f0c5a84489..abc2480b0f80 100644 --- a/.github/workflows/ci-frontend.yml +++ b/.github/workflows/ci-frontend.yml @@ -1,12 +1,12 @@ name: Frontend CI on: pull_request: - # Draft PRs run a narrowed jest selection; ready PRs run the full matrix - # (the merge gate). ready_for_review re-triggers the full run on the - # current head when a PR leaves draft. To force the full matrix on a - # draft, add the `run-ci-frontend` label — labeled/unlabeled re-trigger - # the run so it starts without needing a new push. - types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + # PRs (draft or ready) run a narrowed jest selection; the full matrix (the + # merge gate) runs on the merge queue's trunk-merge/** draft PRs, and the + # queue ejects the PR when it fails. To force the full matrix on a PR, add + # the `run-ci-frontend` label — labeled/unlabeled re-trigger the run so it + # starts without needing a new push. + types: [opened, synchronize, reopened, labeled, unlabeled] push: branches: - master @@ -159,14 +159,14 @@ jobs: token: ${{ steps.app-token.outputs.token || github.token }} filters: ${{ runner.temp }}/frontend-exclude.yml - # Pick which jest tests to run on draft PRs by passing changed files to + # Pick which jest tests to run on PRs by passing changed files to # `jest --findRelatedTests` (jest's resolver walks the import graph for us). - # Skipped on ready PRs and master pushes; the jest job falls back - # to its full FOSS×EE × chunk matrix in those cases. The ready-for-review - # full run is the merge gate. The run-ci-frontend label forces the full - # matrix on a draft. When selection can't be trusted on a draft (config - # change, oversized diff, selector failure), the draft skips jest entirely - # and defers to the ready full run. + # Skipped on the merge queue's trunk-merge/** PRs and master pushes; the jest + # job falls back to its full FOSS×EE × chunk matrix in those cases. The + # merge-queue full run is the merge gate. The run-ci-frontend label forces + # the full matrix on a PR. When selection can't be trusted (config change, + # oversized diff, selector failure), the PR skips jest entirely and defers + # to the merge-queue full run. select-jest-tests: name: Select jest tests runs-on: ubuntu-latest @@ -175,7 +175,7 @@ jobs: if: | always() && needs.changes.outputs.frontend_code == 'true' && github.event_name == 'pull_request' - && github.event.pull_request.draft == true + && !startsWith(github.head_ref, 'trunk-merge/') && !contains(github.event.pull_request.labels.*.name, 'run-ci-frontend') outputs: mode: ${{ steps.classify.outputs.mode }} @@ -208,12 +208,12 @@ jobs: run: | set -euo pipefail - # Untrusted selection on a draft skips jest entirely (should_run=false - # gates the jest job off); the ready-for-review full run is the gate. + # Untrusted selection skips jest entirely (should_run=false gates + # the jest job off); the merge-queue full run is the gate. # Use this whenever the selector can't reason about the diff — a # narrowed run it can't vouch for would be false confidence, and a - # full run on every draft push is the spend this selection avoids. - # The run-ci-frontend label forces the full matrix on a draft. + # full run on every PR push is the spend this selection avoids. + # The run-ci-frontend label forces the full matrix on a PR. fall_back_to_skip() { echo "mode=skip" >> "$GITHUB_OUTPUT" echo 'matrix={"include":[]}' >> "$GITHUB_OUTPUT" @@ -222,14 +222,14 @@ jobs: } if [[ "$DIFF_OUTCOME" != "success" ]] || [[ ! -s /tmp/changed.txt ]]; then - echo "::warning::could not derive changed files; draft skips jest (full run happens on ready for review)" + echo "::warning::could not derive changed files; skipping jest (full run happens in the merge queue)" fall_back_to_skip exit 0 fi changed_count=$(wc -l < /tmp/changed.txt) if [[ "$changed_count" -gt 200 ]]; then - echo "::notice::diff has $changed_count files (>200); draft skips jest (full run happens on ready for review)" + echo "::notice::diff has $changed_count files (>200); skipping jest (full run happens in the merge queue)" fall_back_to_skip exit 0 fi @@ -244,7 +244,7 @@ jobs: jest.*.ts|babel.config.js|tsconfig.json|tsconfig.*.json|\ package.json|pnpm-lock.yaml|webpack.config.js|\ .github/workflows/ci-frontend.yml|.nvmrc) - echo "::notice::config change ($f); draft skips jest (full run happens on ready for review)" + echo "::notice::config change ($f); skipping jest (full run happens in the merge queue)" fall_back_to_skip exit 0 ;; @@ -284,7 +284,7 @@ jobs: # Selective mode: single EE-tree job, no chunk sharding. The FOSS # structural check (ee/ removed) only matters when ee imports are - # touched; defer that to the ready-for-review full run. --findRelatedTests + # touched; defer that to the merge-queue full run. --findRelatedTests # walks jest's import graph from these files and runs the matching # *.test.* files. echo "mode=selective" >> "$GITHUB_OUTPUT" @@ -626,10 +626,10 @@ jobs: jest: runs-on: ubuntu-latest - # Draft PRs run the selective mode as a single unsharded job over every + # PRs run the selective mode as a single unsharded job over every # test reachable from the changed files. A PR touching a scene component # can pull in ~700 suites, which does not fit in 15 minutes. 20 covers it; - # the ready-for-review fanout shards well below either limit. + # the merge-queue fanout shards well below either limit. timeout-minutes: 20 needs: [changes, select-jest-tests] if: | @@ -639,11 +639,12 @@ jobs: strategy: # If one test fails, still run the others fail-fast: false - # On draft PRs, select-jest-tests may narrow this to a single EE job + # On PRs, select-jest-tests may narrow this to a single EE job # running only the tests reachable from changed frontend source files, # or skip jest entirely (should_run=false) when selection can't be - # trusted. On ready PRs and master pushes it's skipped, so - # we fall back to the full FOSS×EE × chunk fanout (the merge gate). + # trusted. On merge-queue trunk-merge/** PRs and master pushes it's + # skipped, so we fall back to the full FOSS×EE × chunk fanout (the + # merge gate). matrix: ${{ fromJson(needs.select-jest-tests.outputs.matrix || '{"segment":["FOSS","EE"],"chunk":[1,2,3,4]}') }} steps: @@ -690,7 +691,7 @@ jobs: # matches nothing), and CHANGED_FILES is already scoped to # frontend source files by select-jest-tests. --passWithNoTests # so a changed source file with no related test doesn't fail the - # run — the ready-for-review full matrix is the gate. + # run — the merge-queue full matrix is the gate. pnpm --filter=@posthog/frontend build:products # shellcheck disable=SC2086 pnpm --filter=@posthog/frontend exec jest \ diff --git a/.github/workflows/ci-mcp.yml b/.github/workflows/ci-mcp.yml index 60717dea18f6..63a7b46397f0 100644 --- a/.github/workflows/ci-mcp.yml +++ b/.github/workflows/ci-mcp.yml @@ -4,10 +4,10 @@ on: push: branches: [master] pull_request: - # Draft PRs run build + unit tests only; the integration tests (which boot - # the full PostHog backend and trigger on any Python change) run when the - # PR is marked ready for review — that full run is the merge gate. - types: [opened, synchronize, reopened, ready_for_review] + # PRs run build + unit tests only; the integration tests (which boot the + # full PostHog backend and trigger on any Python change) run on the merge + # queue's trunk-merge/** draft PRs — that queue run is the merge gate. + types: [opened, synchronize, reopened] concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} @@ -240,11 +240,14 @@ jobs: runs-on: depot-ubuntu-24.04-4 needs: changes timeout-minutes: 30 - # Skipped on drafts: these boot the full PostHog backend and trigger on any - # Python change, so they're the expensive half of this workflow. Build and - # unit tests still run on drafts; the `MCP Tests Pass` aggregator treats - # skipped as success, and the ready-for-review run is the merge gate. - if: needs.changes.outputs.mcp == 'true' && github.event.pull_request.draft != true + # Skipped on regular PRs: these boot the full PostHog backend and trigger on + # any Python change, so they're the expensive half of this workflow. Build + # and unit tests still run on PRs; the `MCP Tests Pass` aggregator treats + # skipped as success, and the merge queue's trunk-merge/** run is the merge + # gate. + if: | + needs.changes.outputs.mcp == 'true' && + (github.event_name != 'pull_request' || startsWith(github.head_ref, 'trunk-merge/')) env: DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USER }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/ci-storybook.yml b/.github/workflows/ci-storybook.yml index 2611daf8c59d..9010472bf997 100644 --- a/.github/workflows/ci-storybook.yml +++ b/.github/workflows/ci-storybook.yml @@ -1,12 +1,13 @@ name: Storybook on: pull_request: - # Draft PRs run a narrowed visual-regression matrix when the Storybook module - # graph can safely identify affected stories. Ready PRs run the full chromium - # matrix as the merge gate. To force the full matrix on a draft, add the - # `run-ci-frontend` label — - # labeled/unlabeled re-trigger the run so it starts without needing a push. - types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + # PRs (draft or ready) run a narrowed visual-regression matrix when the + # Storybook module graph can safely identify affected stories. The full + # chromium matrix (the merge gate) runs on the merge queue's trunk-merge/** + # draft PRs. To force the full matrix on a PR, add the `run-ci-frontend` + # label — labeled/unlabeled re-trigger the run so it starts without needing + # a push. + types: [opened, synchronize, reopened, labeled, unlabeled] push: branches: - master @@ -755,8 +756,9 @@ jobs: needs: [select-stories] runs-on: ubuntu-latest timeout-minutes: 5 - # select-stories only runs on internal draft PRs; fire only when it produced a - # decision. DevEx project only; continue-on-error so telemetry never reds CI. + # select-stories only runs on internal PRs outside the merge queue; fire only + # when it produced a decision. DevEx project only; continue-on-error so + # telemetry never reds CI. if: | always() && github.run_attempt == '1' && github.actor != 'dependabot[bot]' @@ -786,11 +788,11 @@ jobs: "run_id": ${{ toJSON(github.run_id) }} } - # Pick a narrowed visual-regression matrix for draft PRs based on which stories - # the Vite module graph says are affected by the diff. Ready PRs and drafts with - # the run-ci-frontend label skip this job and use the full chromium matrix. - # Selector uncertainty skips draft visual regression because the ready run is - # still the merge gate. + # Pick a narrowed visual-regression matrix for PRs based on which stories + # the Vite module graph says are affected by the diff. The merge queue's + # trunk-merge/** PRs and PRs with the run-ci-frontend label skip this job + # and use the full chromium matrix. Selector uncertainty skips PR visual + # regression because the merge-queue run is the merge gate. select-stories: name: Story selection runs-on: ubuntu-latest @@ -799,7 +801,7 @@ jobs: if: | always() && needs.changes.outputs.frontend == 'true' && github.event_name == 'pull_request' - && github.event.pull_request.draft == true + && !startsWith(github.head_ref, 'trunk-merge/') && !contains(github.event.pull_request.labels.*.name, 'run-ci-frontend') outputs: mode: ${{ steps.classify.outputs.mode }} @@ -891,14 +893,14 @@ jobs: } if [[ "$SELECT_OUTCOME" != "success" ]] || [[ ! -s /tmp/selection-result.json ]]; then - echo "::warning::story selector did not produce output; skipping draft visual regression" + echo "::warning::story selector did not produce output; skipping PR visual regression" fall_back_to_skip selector_error exit 0 fi MODE=$(jq -r '.mode' /tmp/selection-result.json) if [[ "$MODE" != "selective" ]]; then - echo "Selector declined to narrow (mode=$MODE); skipping draft visual regression" + echo "Selector declined to narrow (mode=$MODE); skipping PR visual regression" fall_back_to_skip "$(jq -r '.reason // "full_requested"' /tmp/selection-result.json)" exit 0 fi @@ -910,7 +912,7 @@ jobs: echo "should_run=false" >> "$GITHUB_OUTPUT" echo "affected_files_json=[]" >> "$GITHUB_OUTPUT" emit_metrics true no_affected_stories - echo "Selective mode: 0 affected stories, skipping draft visual regression" + echo "Selective mode: 0 affected stories, skipping PR visual regression" exit 0 fi diff --git a/.github/workflows/trunk-impacted-targets.yml b/.github/workflows/trunk-impacted-targets.yml new file mode 100644 index 000000000000..dae495a8e866 --- /dev/null +++ b/.github/workflows/trunk-impacted-targets.yml @@ -0,0 +1,91 @@ +# Uploads this PR's impacted targets to Trunk so the merge queue can schedule +# it. Trunk needs an impacted-targets upload to place a PR into a parallel-queue +# lane; without one, fork PRs never enter the queue and have to be merged by +# hand. +# +# The upload authenticates with the org API token (x-api-token) on internal PRs. +# Fork PR workflow runs do NOT receive repo secrets — GitHub withholds them from +# `pull_request` runs originating in a fork — so for forks we authenticate with +# the workflow run id instead (x-forked-workflow-run-id). Trunk verifies the run +# id belongs to a live fork-PR workflow whose head SHA matches the payload. +# Ref: https://docs.trunk.io/merge-queue/optimizations/parallel-queues/api#handling-forked-pull-requests +# +# SECURITY: this MUST stay on the `pull_request` trigger, never +# `pull_request_target`. `pull_request_target` would hand repo secrets to code +# from untrusted forks ("pwn request"). The fork path deliberately needs no +# secret, so the plain `pull_request` trigger is sufficient and safe. +name: Trunk Impacted Targets + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + # Supersede in-flight uploads when the PR head moves; the last upload per + # head SHA wins on Trunk's side anyway. Key on the PR number, not head_ref: + # fork PRs frequently share a source branch name (many are opened from + # `master`), and keying on head_ref would put two such PRs in one group + # where one cancels the other's upload — leaving that head SHA without the + # upload it needs to enter the queue. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + upload: + runs-on: ubuntu-latest + timeout-minutes: 5 + # The queue's own trunk-merge/** test PRs never need to enter the queue. + if: ${{ !startsWith(github.head_ref, 'trunk-merge/') }} + # Reads only the event payload and talks out to Trunk — no repo write or + # PR API access needed. + permissions: + contents: read + env: + IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + RUN_ID: ${{ github.run_id }} + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + # Head SHA (not the synthetic merge commit): it matches the workflow + # run's head_sha, which is what Trunk checks when verifying a fork + # upload. + PR_SHA: ${{ github.event.pull_request.head.sha }} + TARGET_BRANCH: ${{ github.event.pull_request.base.ref }} + TRUNK_API_TOKEN: ${{ secrets.TRUNK_API_TOKEN }} + steps: + - name: Upload impacted targets to Trunk + # Not continue-on-error: a silent failure here keeps the PR out of + # the queue, so surface upload problems loudly. --retry rides out + # transient Trunk/network blips. + run: | + set -euo pipefail + + # We report "ALL" — every target — which is always correct: it + # can never under-report and let the queue merge conflicting + # PRs in parallel. It yields no parallelism benefit, but it + # gets every PR (forks included) into the queue. To actually + # parallelise, replace "ALL" with a computed target-name list + # once the queue's target names are defined; see the Trunk + # bazel-action reference impl. + payload="$(jq -n \ + --arg host "github.com" \ + --arg owner "$REPO_OWNER" \ + --arg name "$REPO_NAME" \ + --argjson number "$PR_NUMBER" \ + --arg sha "$PR_SHA" \ + --arg targetBranch "$TARGET_BRANCH" \ + '{repo: {host: $host, owner: $owner, name: $name}, pr: {number: $number, sha: $sha}, targetBranch: $targetBranch, impactedTargets: "ALL"}')" + + if [ "$IS_FORK" = "true" ]; then + echo "Fork PR — authenticating with x-forked-workflow-run-id ($RUN_ID)" + auth_header="x-forked-workflow-run-id: $RUN_ID" + else + echo "Internal PR — authenticating with x-api-token" + auth_header="x-api-token: $TRUNK_API_TOKEN" + fi + + curl --fail --silent --show-error --retry 3 --retry-all-errors \ + -X POST "https://api.trunk.io/v1/setImpactedTargets" \ + -H "Content-Type: application/json" \ + -H "$auth_header" \ + --data "$payload" From 0eaf5e74c9add935d391f09401c68d2cc8475803 Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:00:30 +0000 Subject: [PATCH 2/3] chore: update OpenAPI generated types --- services/mcp/src/generated/platform_features/api.ts | 6 ++++++ services/mcp/src/tools/generated/platform_features.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/services/mcp/src/generated/platform_features/api.ts b/services/mcp/src/generated/platform_features/api.ts index 88e6df8b8879..da266041e4f4 100644 --- a/services/mcp/src/generated/platform_features/api.ts +++ b/services/mcp/src/generated/platform_features/api.ts @@ -35,6 +35,12 @@ export const PartialUpdateBody = /* @__PURE__ */ zod.object({ 'When True, organization members (below admin) are allowed to create new projects. Admins and owners can always create projects.' ), members_can_use_personal_api_keys: zod.boolean().optional(), + members_can_see_org_members: zod + .boolean() + .optional() + .describe( + 'When False, members (below admin) only see themselves in the members list and only project members in access control.' + ), allow_publicly_shared_resources: zod.boolean().optional(), is_ai_data_processing_approved: zod.boolean().nullish(), is_ai_training_opted_in: zod diff --git a/services/mcp/src/tools/generated/platform_features.ts b/services/mcp/src/tools/generated/platform_features.ts index c6c8fbd77f66..2fa82bcae245 100644 --- a/services/mcp/src/tools/generated/platform_features.ts +++ b/services/mcp/src/tools/generated/platform_features.ts @@ -540,6 +540,7 @@ const OrganizationEnforce2faSchema = PartialUpdateParams.extend( members_can_invite: true, members_can_create_projects: true, members_can_use_personal_api_keys: true, + members_can_see_org_members: true, allow_publicly_shared_resources: true, is_ai_data_processing_approved: true, is_ai_training_opted_in: true, From 4b5c183cc6b066c088566a7502bc3b1c30a298d7 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Thu, 23 Jul 2026 07:54:51 -0400 Subject: [PATCH 3/3] chore(ci): mirror merge-queue gating into depot ci-backend shadow Co-Authored-By: Claude Fable 5 --- .depot/workflows/ci-backend.yml | 70 +++++++++++++++++---------------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/.depot/workflows/ci-backend.yml b/.depot/workflows/ci-backend.yml index 1ab3502bcdb4..7b1a8121508c 100644 --- a/.depot/workflows/ci-backend.yml +++ b/.depot/workflows/ci-backend.yml @@ -88,12 +88,12 @@ on: description: ClickHouse server version. Leave blank for default type: string pull_request: - # Draft PRs run the snob-selected Django subset (≤3 shards); turbo-tests - # still skip drafts. Ready PRs run the full matrices — the merge gate — - # and ready_for_review re-triggers them when a PR leaves draft. Add the - # `run-ci-backend` label (labeled/unlabeled re-trigger) to force the full - # matrices on a draft. Mirrors canonical. - types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + # PRs (draft or ready) run the snob-selected Django subset (≤3 shards); + # turbo-tests skip PRs. The full matrices run on the merge queue's + # trunk-merge/** draft PRs — the merge gate. Add the `run-ci-backend` + # label (labeled/unlabeled re-trigger) to force the full matrices on a + # PR. Mirrors canonical. + types: [opened, synchronize, reopened, labeled, unlabeled] concurrency: # PRs: one active run per branch, cancel stale. Push: per-SHA so master # pushes never cancel each other (check-migrations always completes). @@ -533,12 +533,12 @@ jobs: echo "run_legacy=$(echo "$RESULT" | jq -r '.run_legacy')" >> $GITHUB_OUTPUT echo "django_shards=$(echo "$RESULT" | jq -c '.django_shards // empty')" >> $GITHUB_OUTPUT - # Pick which Django tests to run on draft PRs. Drafts get the snob-selected - # subset for fast feedback; the full matrix runs once the PR is marked ready - # for review, and that ready run is the merge gate. When selection can't be - # trusted on a draft (legacy graph impact, turbo-discover or selector failure, - # a selector full-run signal), the draft skips the heavy matrices entirely — - # the pre-selection draft behavior — and defers to the ready full run. + # Pick which Django tests to run on PRs. PRs get the snob-selected subset for + # fast feedback; the full matrix runs on the merge queue's trunk-merge/** test + # PRs, and that queue run is the merge gate. When selection can't be trusted + # on a PR (legacy graph impact, turbo-discover or selector failure, a selector + # full-run signal), the PR skips the heavy matrices entirely and defers to the + # merge-queue full run. # # Depot shadow note: canonical uploads /tmp/selection.json for its # test-selection-verdict job. The depot shadow strips that job (and all @@ -546,13 +546,13 @@ jobs: select-tests: name: Select tests needs: [changes, turbo-discover] - # Only draft PRs do selective runs; ready PRs and pushes always run full, - # which build_django_matrix falls back to + # Only regular PRs do selective runs; merge-queue trunk-merge/** PRs and + # pushes always run full, which build_django_matrix falls back to # when select-tests is skipped (empty MODE). The run-ci-backend label - # forces the full matrices on a draft. + # forces the full matrices on a PR. if: | github.event_name == 'pull_request' && - github.event.pull_request.draft == true && + !startsWith(github.head_ref, 'trunk-merge/') && !contains(github.event.pull_request.labels.*.name, 'run-ci-backend') && needs.changes.outputs.backend == 'true' runs-on: depot-ubuntu-24.04 @@ -598,7 +598,7 @@ jobs: if [[ "$RUN_LEGACY" == "true" ]]; then # turbo-discover detected product->legacy graph impact; the # diff-based selector can't see this, so its subset would be - # incomplete. Skip the draft matrices; the ready run is full. + # incomplete. Skip the selective matrices; the merge-queue run is full. skip=true elif [[ "$TURBO_RESULT" != "success" && "$TURBO_RESULT" != "skipped" ]]; then # Conservative — turbo-discover failed. @@ -614,7 +614,7 @@ jobs: id: select # continue-on-error so a selector or git fetch failure doesn't fail this # job — a failed select-tests would leave MODE empty and build_django_matrix - # would fall back to an expensive full matrix on a draft. Classify reads + # would fall back to an expensive full matrix on a PR. Classify reads # steps.select.outcome (pre-continue-on-error) and emits mode=skip instead. continue-on-error: true if: steps.fallback.outputs.skip == 'false' @@ -658,8 +658,8 @@ jobs: } >> "$GITHUB_OUTPUT" } - # Untrusted selection on a draft skips the heavy matrices (the - # pre-selection draft behavior); the ready-for-review run is full. + # Untrusted selection skips the heavy matrices; the merge-queue + # run is full. fall_back_to_skip() { echo "mode=skip" >> "$GITHUB_OUTPUT" echo "core_files=" >> "$GITHUB_OUTPUT" @@ -676,14 +676,14 @@ jobs: fi if [[ "$SELECT_OUTCOME" != "success" ]] || [[ ! -s /tmp/selection.json ]]; then - echo "::warning::shadow selector did not produce output; draft skips heavy matrices (full run happens on ready for review)" + echo "::warning::shadow selector did not produce output; skipping heavy matrices (full run happens in the merge queue)" fall_back_to_skip selector_error exit 0 fi full_run_reasons=$(jq -r '.ast.full_run_reasons | length' /tmp/selection.json) if [[ "$full_run_reasons" -gt 0 ]]; then - echo "Selector requested a full run; draft defers it to ready for review:" + echo "Selector requested a full run; deferring it to the merge queue:" jq -r '.ast.full_run_reasons[]' /tmp/selection.json fall_back_to_skip full_run_requested exit 0 @@ -758,12 +758,14 @@ jobs: # 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 + # Skipped on regular PRs — the merge queue's trunk-merge/** run is the full gate; + # the run-ci-backend label forces them on a PR. # A status function is required to inspect dependency results after failures. # Use !cancelled(), not always(), so superseded matrix jobs still receive cancellation. turbo-tests: needs: [changes, turbo-discover, detect-snapshot-mode, build-product-test-matrix, get_clickhouse_versions] if: >- - !cancelled() && (github.event.pull_request.draft != true || contains(github.event.pull_request.labels.*.name, 'run-ci-backend')) && needs.turbo-discover.result == 'success' && needs.build-product-test-matrix.result == 'success' && needs.build-product-test-matrix.outputs.include != '[]' && needs.build-product-test-matrix.outputs.include != '' + !cancelled() && (github.event_name != 'pull_request' || startsWith(github.head_ref, 'trunk-merge/') || contains(github.event.pull_request.labels.*.name, 'run-ci-backend')) && needs.turbo-discover.result == 'success' && needs.build-product-test-matrix.result == 'success' && needs.build-product-test-matrix.outputs.include != '[]' && needs.build-product-test-matrix.outputs.include != '' runs-on: depot-ubuntu-24.04 timeout-minutes: 40 name: Product tests (${{ matrix.group }}, events schema ${{ matrix.new-events-schema && 'json' || 'legacy' }}) @@ -1266,25 +1268,25 @@ jobs: RUN_POE: ${{ needs.select-tests.outputs.run_poe }} RUN_TEMPORAL: ${{ needs.select-tests.outputs.run_temporal }} CORE_FILES: ${{ needs.select-tests.outputs.core_files }} - IS_DRAFT: ${{ github.event.pull_request.draft }} + IS_SELECTIVE_PR: ${{ github.event_name == 'pull_request' && !startsWith(github.head_ref, 'trunk-merge/') }} FORCE_FULL: ${{ contains(github.event.pull_request.labels.*.name, 'run-ci-backend') }} run: | # :NOTE: Keep shard counts/group ranges in sync with historical Django matrix tuning. # Consult #team-devex before changing. - # A draft PR must never fall back to the full matrix. select-tests only - # runs on drafts and sets MODE to "skip"/"selected"; an empty MODE on a - # draft means select-tests was cancelled or failed (typically - # ready_for_review superseding it mid-flight). Skip here and defer to the - # ready run. The run-ci-backend label intentionally forces full on a draft. - if [[ -z "$MODE" && "$IS_DRAFT" == "true" && "$FORCE_FULL" != "true" ]]; then - echo "::notice::Draft PR with no trusted Django selection — skipping heavy matrices; the ready-for-review run is the full gate" + # A regular PR must never fall back to the full matrix. select-tests only + # runs on regular PRs and sets MODE to "skip"/"selected"; an empty MODE on + # one means select-tests was cancelled or failed (typically a newer push + # superseding it mid-flight). Skip here and defer to the merge-queue run — + # the actual merge gate. The run-ci-backend label intentionally forces full. + if [[ -z "$MODE" && "$IS_SELECTIVE_PR" == "true" && "$FORCE_FULL" != "true" ]]; then + echo "::notice::PR with no trusted Django selection — skipping heavy matrices; the merge-queue run is the full gate" MODE="skip" fi if [[ "$MODE" == "skip" ]]; then - # Draft PR where selection couldn't be trusted: skip the heavy - # matrices and defer to the full run on ready for review. + # PR where selection couldn't be trusted: skip the heavy + # matrices and defer to the full run in the merge queue. echo "include=[]" >> "$GITHUB_OUTPUT" echo "Django matrix size: 0 (mode=skip)" exit 0 @@ -1512,7 +1514,7 @@ jobs: # 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) - # The non-empty include guard covers drafts in skip mode and selected runs + # The non-empty include guard covers PRs in skip mode and selected runs # that chose zero files — an empty matrix would otherwise fail the job. # A status function is required to inspect dependency results after failures. # Use !cancelled(), not always(), so superseded matrix jobs still receive cancellation.