Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .agents/skills/authoring-ci-workflows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -211,11 +211,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

Expand Down
87 changes: 44 additions & 43 deletions .depot/workflows/ci-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,14 @@ 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 to force the full matrices on a draft; the
# `no-ci` label silences the workflow on a draft entirely. No
# labeled/unlabeled triggers, so a label takes effect from the next push
# or from ready_for_review. Mirrors canonical.
types: [opened, synchronize, reopened, ready_for_review]
# 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 to force the full matrices on a PR; the `no-ci` label silences
# the workflow on a draft entirely. No labeled/unlabeled or
# ready_for_review triggers, so a label takes effect from the next push.
# Mirrors canonical.
types: [opened, synchronize, reopened]
concurrency:
# PRs: one active run per branch, cancel stale. Push: per-SHA so master
# pushes never cancel each other (check-migrations always completes).
Expand Down Expand Up @@ -549,29 +549,28 @@ 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.
# hogli-lint: not-a-required-gate - selects draft coverage and emits no required check.
# 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.
# hogli-lint: not-a-required-gate - selects PR coverage and emits no required check.
#
# Depot shadow note: canonical uploads /tmp/selection.json for its
# test-selection-verdict job. The depot shadow strips that job (and all
# artifact uploads), so this job has no Upload selection artifact step.
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.
# Trunk's merge-queue branches open as draft PRs, but their run IS the
# merge gate, so they must never get the narrowed selection.
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'
Expand Down Expand Up @@ -618,7 +617,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.
Expand All @@ -634,7 +633,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'
Expand Down Expand Up @@ -678,8 +677,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"
Expand All @@ -696,14 +695,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
Expand Down Expand Up @@ -778,12 +777,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 || 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 != ''
!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' }})
Expand Down Expand Up @@ -1286,31 +1287,31 @@ 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 }}
# A trunk-merge/** PR opens as a draft but is the merge gate, and
# select-tests deliberately does not run for it. Reporting it as a
# draft here would turn that empty MODE into "skip" and build an
# empty matrix, so the queue would merge having run no Django tests
# at all. Not-draft makes the empty MODE fall through to the full
# matrix, which is what the queue has to run.
IS_DRAFT: ${{ github.event.pull_request.draft && !startsWith(github.head_ref, 'trunk-merge/') }}
# trunk-merge/** is excluded because select-tests deliberately does
# not run for it. Counting it as selective would turn that empty MODE
# into "skip" and build an empty matrix, so the queue would merge
# having run no Django tests at all. Leaving it non-selective makes
# the empty MODE fall through to the full matrix, which is what the
# queue has to run.
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
Expand Down Expand Up @@ -1538,7 +1539,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.
Expand Down
Loading
Loading