Skip to content

Commit f660f16

Browse files
authored
feat(infra): Add backend selective testing workflow (#105500)
Adds a "shadow job" (i.e. non-required workflow) to run on PRs alongside backend tests leveraging selective testing. This selective testing strategy uses changed files and code coverage data to determine what test files to run based on what files changed. Our approach is quite lax here, when in doubt (missing coverage data, file in an allowlist) we'll default to running all tests. But generally we want to gather data from this as a non-blocking PR check so this seems like the best start and we can iteratively improve.
1 parent 263a326 commit f660f16

File tree

4 files changed

+456
-8
lines changed

4 files changed

+456
-8
lines changed
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
name: '[NOT REQUIRED] backend (selective)'
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'src/sentry/preprod/**'
7+
8+
concurrency:
9+
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
10+
cancel-in-progress: true
11+
12+
# hack for https://github.com/actions/cache/issues/810#issuecomment-1222550359
13+
env:
14+
SEGMENT_DOWNLOAD_TIMEOUT_MINS: 3
15+
SNUBA_NO_WORKERS: 1
16+
17+
jobs:
18+
files-changed:
19+
name: detect what files changed
20+
runs-on: ubuntu-24.04
21+
timeout-minutes: 3
22+
continue-on-error: true
23+
outputs:
24+
api_docs: ${{ steps.changes.outputs.api_docs }}
25+
backend: ${{ steps.changes.outputs.backend_all }}
26+
backend_dependencies: ${{ steps.changes.outputs.backend_dependencies }}
27+
backend_api_urls: ${{ steps.changes.outputs.backend_api_urls }}
28+
backend_any_type: ${{ steps.changes.outputs.backend_any_type }}
29+
migration_lockfile: ${{ steps.changes.outputs.migration_lockfile }}
30+
steps:
31+
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
32+
33+
- name: Check for backend file changes
34+
uses: dorny/paths-filter@0bc4621a3135347011ad047f9ecf449bf72ce2bd # v3.0.0
35+
id: changes
36+
with:
37+
token: ${{ github.token }}
38+
filters: .github/file-filters.yml
39+
40+
prepare-selective-tests:
41+
if: needs.files-changed.outputs.backend == 'true'
42+
needs: files-changed
43+
name: prepare selective tests
44+
runs-on: ubuntu-24.04
45+
timeout-minutes: 10
46+
continue-on-error: true
47+
permissions:
48+
contents: read
49+
id-token: write
50+
outputs:
51+
has-coverage: ${{ steps.find-coverage.outputs.found }}
52+
coverage-sha: ${{ steps.find-coverage.outputs.coverage-sha }}
53+
changed-files: ${{ steps.changed-files.outputs.files }}
54+
test-count: ${{ steps.compute-tests.outputs.test-count }}
55+
has-selected-tests: ${{ steps.compute-tests.outputs.has-selected-tests }}
56+
steps:
57+
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
58+
with:
59+
fetch-depth: 0 # Need full history for git diff
60+
61+
- name: Setup Python
62+
uses: actions/setup-python@v5
63+
with:
64+
python-version: '3.13.1'
65+
66+
- name: Authenticate to Google Cloud
67+
id: gcloud-auth
68+
uses: google-github-actions/auth@v2
69+
with:
70+
project_id: sentry-dev-tooling
71+
workload_identity_provider: ${{ secrets.SENTRY_GCP_DEV_WORKLOAD_IDENTITY_POOL }}
72+
service_account: ${{ secrets.COLLECT_TEST_DATA_SERVICE_ACCOUNT_EMAIL }}
73+
74+
- name: Find coverage data for selective testing
75+
id: find-coverage
76+
env:
77+
GCS_BUCKET: sentry-coverage-data
78+
run: |
79+
set -euo pipefail
80+
81+
# Get the base commit (what the PR branches from)
82+
BASE_SHA="${{ github.event.pull_request.base.sha }}"
83+
84+
echo "Looking for coverage data starting from base commit: $BASE_SHA"
85+
86+
COVERAGE_SHA=""
87+
for sha in $(git rev-list "$BASE_SHA" --max-count=30); do
88+
# Check if coverage exists in GCS for this commit
89+
if gcloud storage ls "gs://${GCS_BUCKET}/${sha}/" &>/dev/null; then
90+
COVERAGE_SHA="$sha"
91+
echo "Found coverage data at commit: $sha"
92+
break
93+
fi
94+
echo "No coverage at $sha, checking parent..."
95+
done
96+
97+
if [[ -z "$COVERAGE_SHA" ]]; then
98+
echo "No coverage found in last 30 commits, will run full test suite"
99+
echo "found=false" >> "$GITHUB_OUTPUT"
100+
else
101+
echo "found=true" >> "$GITHUB_OUTPUT"
102+
echo "coverage-sha=$COVERAGE_SHA" >> "$GITHUB_OUTPUT"
103+
fi
104+
105+
- name: Download coverage database
106+
id: download-coverage
107+
if: steps.find-coverage.outputs.found == 'true'
108+
env:
109+
COVERAGE_SHA: ${{ steps.find-coverage.outputs.coverage-sha }}
110+
run: |
111+
set -euxo pipefail
112+
mkdir -p .coverage
113+
114+
if ! gcloud storage cp "gs://sentry-coverage-data/${COVERAGE_SHA}/.coverage.combined" .coverage/; then
115+
echo "Warning: Failed to download coverage file"
116+
echo "coverage-file=" >> "$GITHUB_OUTPUT"
117+
exit 0
118+
fi
119+
120+
if [[ ! -f .coverage/.coverage.combined ]]; then
121+
echo "Warning: Coverage file not found after download"
122+
ls -la .coverage/ || true
123+
echo "coverage-file=" >> "$GITHUB_OUTPUT"
124+
else
125+
echo "Downloaded coverage file: .coverage/.coverage.combined"
126+
echo "coverage-file=.coverage/.coverage.combined" >> "$GITHUB_OUTPUT"
127+
fi
128+
129+
- name: Get changed files
130+
id: changed-files
131+
run: |
132+
# Get files changed between base and head of PR
133+
BASE_SHA="${{ github.event.pull_request.base.sha }}"
134+
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
135+
136+
CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" | tr '\n' ' ')
137+
echo "Changed files: $CHANGED_FILES"
138+
echo "files=$CHANGED_FILES" >> "$GITHUB_OUTPUT"
139+
140+
- name: Compute selected tests
141+
id: compute-tests
142+
if: steps.download-coverage.outputs.coverage-file != ''
143+
env:
144+
COVERAGE_DB: ${{ steps.download-coverage.outputs.coverage-file }}
145+
CHANGED_FILES: ${{ steps.changed-files.outputs.files }}
146+
run: make compute-selected-tests
147+
148+
- name: Upload coverage database artifact
149+
if: steps.download-coverage.outputs.coverage-file != ''
150+
uses: actions/upload-artifact@v4
151+
with:
152+
name: coverage-db-${{ github.run_id }}
153+
path: .coverage/
154+
retention-days: 1
155+
include-hidden-files: true
156+
157+
- name: Upload selected tests artifact
158+
if: steps.compute-tests.outputs.has-selected-tests == 'true'
159+
uses: actions/upload-artifact@v4
160+
with:
161+
name: selected-tests-${{ github.run_id }}
162+
path: .artifacts/selected-tests.txt
163+
retention-days: 1
164+
165+
calculate-shards:
166+
if: needs.files-changed.outputs.backend == 'true'
167+
needs: [files-changed, prepare-selective-tests]
168+
name: calculate test shards
169+
runs-on: ubuntu-24.04
170+
timeout-minutes: 5
171+
continue-on-error: true
172+
outputs:
173+
shard-count: ${{ steps.calculate-shards.outputs.shard-count }}
174+
shard-indices: ${{ steps.calculate-shards.outputs.shard-indices }}
175+
steps:
176+
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
177+
178+
- name: Setup sentry env
179+
uses: ./.github/actions/setup-sentry
180+
id: setup
181+
with:
182+
mode: backend-ci
183+
skip-devservices: true
184+
185+
- name: Download selected tests artifact
186+
if: needs.prepare-selective-tests.outputs.has-selected-tests == 'true'
187+
uses: actions/download-artifact@v4
188+
with:
189+
name: selected-tests-${{ github.run_id }}
190+
path: .artifacts/
191+
192+
- name: Calculate test shards
193+
id: calculate-shards
194+
env:
195+
SELECTED_TESTS_FILE: ${{ needs.prepare-selective-tests.outputs.has-selected-tests == 'true' && '.artifacts/selected-tests.txt' || '' }}
196+
SELECTED_TEST_COUNT: ${{ needs.prepare-selective-tests.outputs.test-count }}
197+
run: |
198+
python3 .github/workflows/scripts/calculate-backend-test-shards.py
199+
200+
backend-test-selective:
201+
if: needs.files-changed.outputs.backend == 'true'
202+
needs: [files-changed, prepare-selective-tests, calculate-shards]
203+
name: backend tests
204+
runs-on: ubuntu-24.04
205+
timeout-minutes: 60
206+
continue-on-error: true
207+
permissions:
208+
contents: read
209+
id-token: write
210+
actions: read # used for DIM metadata
211+
strategy:
212+
fail-fast: false
213+
matrix:
214+
instance: ${{ fromJSON(needs.calculate-shards.outputs.shard-indices) }}
215+
216+
env:
217+
MATRIX_INSTANCE_TOTAL: ${{ needs.calculate-shards.outputs.shard-count }}
218+
TEST_GROUP_STRATEGY: roundrobin
219+
220+
steps:
221+
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
222+
223+
- name: Setup sentry env
224+
uses: ./.github/actions/setup-sentry
225+
id: setup
226+
with:
227+
mode: backend-ci
228+
229+
- name: Download selected tests artifact
230+
if: needs.prepare-selective-tests.outputs.has-selected-tests == 'true'
231+
uses: actions/download-artifact@v4
232+
with:
233+
name: selected-tests-${{ github.run_id }}
234+
path: .artifacts/
235+
236+
- name: Run backend tests (${{ steps.setup.outputs.matrix-instance-number }} of ${{ steps.setup.outputs.matrix-instance-total }})
237+
id: run_backend_tests
238+
run: |
239+
if [[ "${{ needs.prepare-selective-tests.outputs.has-selected-tests }}" == "true" ]]; then
240+
make test-backend-ci-selective SELECTED_TESTS_FILE=.artifacts/selected-tests.txt
241+
else
242+
make test-python-ci
243+
fi
244+
245+
- name: Inspect failure
246+
if: failure()
247+
run: |
248+
if command -v devservices; then
249+
devservices logs
250+
fi
251+
252+
- name: Collect test data
253+
uses: ./.github/actions/collect-test-data
254+
if: ${{ !cancelled() }}
255+
with:
256+
artifact_path: .artifacts/pytest.json
257+
gcs_bucket: ${{ secrets.COLLECT_TEST_DATA_GCS_BUCKET }}
258+
gcp_project_id: ${{ secrets.COLLECT_TEST_DATA_GCP_PROJECT_ID }}
259+
workload_identity_provider: ${{ secrets.SENTRY_GCP_DEV_WORKLOAD_IDENTITY_POOL }}
260+
service_account_email: ${{ secrets.COLLECT_TEST_DATA_SERVICE_ACCOUNT_EMAIL }}
261+
matrix_instance_number: ${{ steps.setup.outputs.matrix-instance-number }}

.github/workflows/scripts/calculate-backend-test-shards.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,49 @@
55
import re
66
import subprocess
77
import sys
8+
from pathlib import Path
89

910
TESTS_PER_SHARD = 1200
1011
MIN_SHARDS = 1
1112
MAX_SHARDS = 22
1213
DEFAULT_SHARDS = 22
1314

14-
PYTEST_ARGS = [
15+
PYTEST_BASE_ARGS = [
1516
"pytest",
1617
"--collect-only",
1718
"--quiet",
18-
"tests",
1919
"--ignore=tests/acceptance",
2020
"--ignore=tests/apidocs",
2121
"--ignore=tests/js",
2222
"--ignore=tests/tools",
2323
]
2424

2525

26-
def collect_test_count():
26+
def collect_test_count() -> int | None:
27+
"""Collect the number of tests to run, either from selected files or full suite."""
28+
selected_tests_file = os.environ.get("SELECTED_TESTS_FILE")
29+
30+
if selected_tests_file:
31+
path = Path(selected_tests_file)
32+
if not path.exists():
33+
print(f"Selected tests file not found: {selected_tests_file}", file=sys.stderr)
34+
return None
35+
36+
with path.open() as f:
37+
selected_files = [line.strip() for line in f if line.strip()]
38+
39+
if not selected_files:
40+
print("No selected test files, running 0 tests", file=sys.stderr)
41+
return 0
42+
43+
print(f"Counting tests in {len(selected_files)} selected files", file=sys.stderr)
44+
pytest_args = PYTEST_BASE_ARGS + selected_files
45+
else:
46+
pytest_args = PYTEST_BASE_ARGS + ["tests"]
47+
2748
try:
2849
result = subprocess.run(
29-
PYTEST_ARGS,
50+
pytest_args,
3051
capture_output=True,
3152
text=True,
3253
check=False,
@@ -40,7 +61,6 @@ def collect_test_count():
4061
print(f"Collected {count} tests", file=sys.stderr)
4162
return count
4263

43-
# If no match, check if pytest failed
4464
if result.returncode != 0:
4565
print(
4666
f"Pytest collection failed (exit {result.returncode})",
@@ -56,7 +76,7 @@ def collect_test_count():
5676
return None
5777

5878

59-
def calculate_shards(test_count):
79+
def calculate_shards(test_count: int | None) -> int:
6080
if test_count is None:
6181
print(f"Using default shard count: {DEFAULT_SHARDS}", file=sys.stderr)
6282
return DEFAULT_SHARDS
@@ -82,10 +102,9 @@ def calculate_shards(test_count):
82102
return bounded
83103

84104

85-
def main():
105+
def main() -> int:
86106
test_count = collect_test_count()
87107
shard_count = calculate_shards(test_count)
88-
# Generate a JSON array of shard indices [0, 1, 2, ..., shard_count-1]
89108
shard_indices = json.dumps(list(range(shard_count)))
90109

91110
github_output = os.getenv("GITHUB_OUTPUT")

0 commit comments

Comments
 (0)