ci: skip heavy CI for non-impacting changes #2312
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| push: | |
| branches: | |
| - main | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| changes: | |
| name: Classify changed files | |
| runs-on: ubuntu-latest | |
| outputs: | |
| skip_heavy: ${{ steps.classify.outputs.skip_heavy }} | |
| steps: | |
| - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Classify changed files | |
| id: classify | |
| shell: bash | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| PR_FILES_URL: ${{ github.event.pull_request.url }}/files | |
| run: | | |
| set -u | |
| python3 <<'PY' | |
| import json | |
| import os | |
| import sys | |
| import urllib.error | |
| import urllib.request | |
| from fnmatch import fnmatchcase | |
| SAFE_WORKFLOW_NAMES = { | |
| "release", | |
| "publish", | |
| "stale", | |
| "lint-pr", | |
| "call-flags-project-board", | |
| "changeset-hygiene", | |
| "dependabot-changeset", | |
| "validate-versioning", | |
| "new-pr", | |
| "generated-files-notice", | |
| "posthog-upgrade", | |
| } | |
| def is_safe_global_file(path): | |
| if not isinstance(path, str) or not path: | |
| return False | |
| if path.endswith(".md"): | |
| return True | |
| if path.startswith("docs/"): | |
| return True | |
| if path.startswith(".github/ISSUE_TEMPLATE/"): | |
| return True | |
| if path.startswith(".github/PULL_REQUEST_TEMPLATE/"): | |
| return True | |
| if path in { | |
| ".github/pull_request_template.md", | |
| ".github/CODEOWNERS", | |
| "CODEOWNERS", | |
| ".github/dependabot.yml", | |
| }: | |
| return True | |
| workflow_prefix = ".github/workflows/" | |
| if path.startswith(workflow_prefix) and (path.endswith(".yml") or path.endswith(".yaml")): | |
| workflow_name = path[len(workflow_prefix):].rsplit(".", 1)[0] | |
| return workflow_name in SAFE_WORKFLOW_NAMES or fnmatchcase(workflow_name, "posthog-watcher-*") | |
| return False | |
| def get_pr_files(files_url, token): | |
| files = [] | |
| page = 1 | |
| per_page = 100 | |
| while True: | |
| separator = "&" if "?" in files_url else "?" | |
| url = f"{files_url}{separator}per_page={per_page}&page={page}" | |
| request = urllib.request.Request( | |
| url, | |
| headers={ | |
| "Accept": "application/vnd.github+json", | |
| "Authorization": f"Bearer {token}", | |
| "X-GitHub-Api-Version": "2022-11-28", | |
| "User-Agent": "posthog-python-ci-change-classifier", | |
| }, | |
| ) | |
| with urllib.request.urlopen(request, timeout=30) as response: | |
| page_files = json.loads(response.read().decode("utf-8")) | |
| if not isinstance(page_files, list): | |
| raise ValueError("pull request files API did not return a list") | |
| if not page_files: | |
| break | |
| files.extend(page_files) | |
| if len(page_files) < per_page: | |
| break | |
| page += 1 | |
| return files | |
| def print_files(heading, files): | |
| if not files: | |
| return | |
| print(heading) | |
| for file in files: | |
| print(f" - {file}") | |
| def main(): | |
| skip_heavy = False | |
| reason = "Running full CI by default." | |
| changed_files = [] | |
| safe_files = [] | |
| full_ci_files = [] | |
| event_name = os.environ.get("GITHUB_EVENT_NAME", "") | |
| files_url = os.environ.get("PR_FILES_URL", "") | |
| token = os.environ.get("GITHUB_TOKEN", "") | |
| if event_name != "pull_request": | |
| reason = f"{event_name or 'unknown'} event; running full CI." | |
| elif not files_url or not token: | |
| reason = "Missing pull request files API URL or token; running full CI." | |
| else: | |
| try: | |
| pr_files = get_pr_files(files_url, token) | |
| except (OSError, TimeoutError, ValueError, json.JSONDecodeError, urllib.error.URLError) as error: | |
| reason = f"Unable to read pull request changed files from the GitHub API; running full CI. ({error})" | |
| else: | |
| if not pr_files: | |
| reason = "No changed files detected by the GitHub API; running full CI." | |
| else: | |
| for item in pr_files: | |
| filename = item.get("filename") if isinstance(item, dict) else None | |
| previous_filename = item.get("previous_filename") if isinstance(item, dict) else None | |
| if isinstance(filename, str) and filename: | |
| changed_files.append(filename) | |
| paths_to_check = [("filename", filename)] | |
| if previous_filename is not None: | |
| paths_to_check.append(("previous_filename", previous_filename)) | |
| for field, file_path in paths_to_check: | |
| if is_safe_global_file(file_path): | |
| label = file_path if field == "filename" else f"{file_path} (previous_filename)" | |
| safe_files.append(label) | |
| else: | |
| if isinstance(file_path, str) and file_path: | |
| label = file_path if field == "filename" else f"{file_path} (previous_filename)" | |
| else: | |
| label = f"empty {field}" | |
| full_ci_files.append(label) | |
| if full_ci_files: | |
| reason = "At least one changed filename or previous_filename is outside the global safe set; running full CI." | |
| elif not safe_files: | |
| reason = "No non-empty changed filenames detected by the GitHub API; running full CI." | |
| else: | |
| skip_heavy = True | |
| reason = "Only globally safe changed filenames and previous_filenames detected; skipping heavy Python CI steps." | |
| github_output = os.environ.get("GITHUB_OUTPUT") | |
| if github_output: | |
| with open(github_output, "a", encoding="utf-8") as output: | |
| output.write(f"skip_heavy={str(skip_heavy).lower()}\n") | |
| print("Changed files:") | |
| if changed_files: | |
| for file in changed_files: | |
| print(file) | |
| else: | |
| print(" (not available)") | |
| print_files("Globally safe files:", safe_files) | |
| print_files("Files requiring full CI:", full_ci_files) | |
| print(f"skip_heavy={str(skip_heavy).lower()}") | |
| print(reason) | |
| step_summary = os.environ.get("GITHUB_STEP_SUMMARY") | |
| if step_summary: | |
| with open(step_summary, "a", encoding="utf-8") as summary: | |
| summary.write("### Changed-file classification\n\n") | |
| summary.write(f"- skip_heavy: `{str(skip_heavy).lower()}`\n") | |
| summary.write(f"- reason: {reason}\n\n") | |
| summary.write("#### Changed files\n") | |
| if changed_files: | |
| for file in changed_files: | |
| summary.write(f"- `{file}`\n") | |
| else: | |
| summary.write("- not available\n") | |
| if __name__ == "__main__": | |
| try: | |
| main() | |
| except Exception as error: # noqa: BLE001 - CI safety valve: API/classifier failures run full CI. | |
| github_output = os.environ.get("GITHUB_OUTPUT") | |
| if github_output: | |
| with open(github_output, "a", encoding="utf-8") as output: | |
| output.write("skip_heavy=false\n") | |
| print("Changed files:") | |
| print(" (not available)") | |
| print("skip_heavy=false") | |
| print(f"Unexpected classifier failure; running full CI. ({error})", file=sys.stderr) | |
| PY | |
| ruff-format: | |
| name: Ruff format | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: ${{ always() }} | |
| steps: | |
| - name: Skip heavy Python CI for non-impacting changes | |
| if: ${{ needs.changes.outputs.skip_heavy == 'true' }} | |
| run: echo "Skipping Ruff format because only non-impacting files changed." | |
| - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| with: | |
| fetch-depth: 1 | |
| - name: Set up Python dev environment | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| uses: ./.github/actions/setup-python-dev | |
| - name: Check formatting with ruff | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| run: | | |
| ruff format --check . | |
| ruff-lint: | |
| name: Ruff lint | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: ${{ always() }} | |
| steps: | |
| - name: Skip heavy Python CI for non-impacting changes | |
| if: ${{ needs.changes.outputs.skip_heavy == 'true' }} | |
| run: echo "Skipping Ruff lint because only non-impacting files changed." | |
| - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| with: | |
| fetch-depth: 1 | |
| - name: Set up Python dev environment | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| uses: ./.github/actions/setup-python-dev | |
| - name: Lint with ruff | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| run: | | |
| ruff check . | |
| mypy: | |
| name: Mypy | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: ${{ always() }} | |
| steps: | |
| - name: Skip heavy Python CI for non-impacting changes | |
| if: ${{ needs.changes.outputs.skip_heavy == 'true' }} | |
| run: echo "Skipping Mypy because only non-impacting files changed." | |
| - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| with: | |
| fetch-depth: 1 | |
| - name: Set up Python dev environment | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| uses: ./.github/actions/setup-python-dev | |
| - name: Check types with mypy | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| run: | | |
| mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter | |
| public-api: | |
| name: Public API snapshot | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: ${{ always() && needs.changes.outputs.skip_heavy != 'true' }} | |
| steps: | |
| - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 | |
| - name: Set up Python dev environment | |
| uses: ./.github/actions/setup-python-dev | |
| - name: Check public API snapshot | |
| run: | | |
| python .github/scripts/test_check_public_api.py | |
| make public_api_check | |
| strict-type-smoke: | |
| name: Strict type smoke | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: ${{ always() && needs.changes.outputs.skip_heavy != 'true' }} | |
| steps: | |
| - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 | |
| with: | |
| fetch-depth: 1 | |
| - name: Set up Python 3.11 | |
| uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 | |
| with: | |
| python-version: 3.11.11 | |
| - name: Check strict downstream Pyright usage | |
| run: .github/scripts/check_strict_types.sh | |
| package-build: | |
| name: Package build | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: ${{ always() }} | |
| steps: | |
| - name: Skip heavy Python CI for non-impacting changes | |
| if: ${{ needs.changes.outputs.skip_heavy == 'true' }} | |
| run: echo "Skipping package build because only non-impacting files changed." | |
| - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| with: | |
| fetch-depth: 1 | |
| - name: Set up Python 3.11 | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 | |
| with: | |
| python-version: 3.11.11 | |
| - name: Install uv | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 | |
| with: | |
| enable-cache: true | |
| - name: Build and verify distributions | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| run: | | |
| uv build | |
| uv run --with twine twine check dist/* | |
| tests: | |
| name: Python ${{ matrix.python-version }} tests | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: ${{ always() }} | |
| strategy: | |
| matrix: | |
| python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] | |
| steps: | |
| - name: Skip heavy Python CI for non-impacting changes | |
| if: ${{ needs.changes.outputs.skip_heavy == 'true' }} | |
| run: echo "Skipping Python ${{ matrix.python-version }} tests because only non-impacting files changed." | |
| - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| with: | |
| fetch-depth: 1 | |
| - name: Set up Python ${{ matrix.python-version }} | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| - name: Install uv | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 | |
| with: | |
| enable-cache: true | |
| - name: Install test dependencies | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| shell: bash | |
| run: | | |
| UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra test | |
| - name: Run posthog tests | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| run: | | |
| pytest --verbose --timeout=30 | |
| mutation-tests: | |
| name: Targeted mutation tests | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: ${{ always() && needs.changes.outputs.skip_heavy != 'true' }} | |
| steps: | |
| - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Check targeted mutation inputs changed | |
| id: changes | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ "${{ github.event_name }}" == "pull_request" ]]; then | |
| base="${{ github.event.pull_request.base.sha }}" | |
| head="${{ github.event.pull_request.head.sha }}" | |
| else | |
| base="${{ github.event.before }}" | |
| head="${{ github.sha }}" | |
| fi | |
| changed_files="$(git diff --name-only "$base" "$head" -- \ | |
| posthog/utils.py \ | |
| posthog/test/test_utils.py \ | |
| posthog/test/test_size_limited_dict.py)" | |
| if [[ -n "$changed_files" ]]; then | |
| echo "changed=true" >> "$GITHUB_OUTPUT" | |
| echo "Targeted mutation inputs changed:" | |
| echo "$changed_files" | |
| else | |
| echo "changed=false" >> "$GITHUB_OUTPUT" | |
| echo "Skipping targeted mutation tests: inputs unchanged." | |
| fi | |
| - name: Set up Python 3.11 | |
| if: steps.changes.outputs.changed == 'true' | |
| uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 | |
| with: | |
| python-version: 3.11.11 | |
| - name: Install uv | |
| if: steps.changes.outputs.changed == 'true' | |
| uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 | |
| with: | |
| enable-cache: true | |
| - name: Check utils CRAP score | |
| if: steps.changes.outputs.changed == 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| UV_PROJECT_ENVIRONMENT=$pythonLocation uv run --extra test --with pytest-crap --with pytest-cov pytest posthog/test/test_utils.py posthog/test/test_size_limited_dict.py --timeout=30 --cov=posthog.utils --crap --crap-threshold=10 --crap-top-n=40 -q | |
| UV_PROJECT_ENVIRONMENT=$pythonLocation uv run --extra test --with pytest-crap --with pytest-cov python .github/scripts/check_crap_threshold.py posthog/utils.py --max-crap 10 | |
| - name: Restore mutmut cache | |
| id: mutmut-cache | |
| if: steps.changes.outputs.changed == 'true' | |
| uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: mutants | |
| key: mutmut-${{ runner.os }}-py311-${{ hashFiles('posthog/utils.py', 'posthog/test/test_utils.py', 'posthog/test/test_size_limited_dict.py') }} | |
| restore-keys: | | |
| mutmut-${{ runner.os }}-py311- | |
| - name: Skip mutation tests on exact cache hit | |
| if: steps.changes.outputs.changed == 'true' && steps.mutmut-cache.outputs.cache-hit == 'true' | |
| run: | | |
| echo "Skipping targeted mutation tests: exact mutmut cache hit." | |
| - name: Run targeted mutation tests | |
| if: steps.changes.outputs.changed == 'true' && steps.mutmut-cache.outputs.cache-hit != 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| UV_PROJECT_ENVIRONMENT=$pythonLocation uv run --extra test --with mutmut mutmut run --max-children 1 | |
| results="$(UV_PROJECT_ENVIRONMENT=$pythonLocation uv run --extra test --with mutmut mutmut results)" | |
| if [[ -n "$results" ]]; then | |
| echo "$results" | |
| exit 1 | |
| fi | |
| import-check: | |
| name: Python ${{ matrix.python-version }} import check | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: ${{ always() }} | |
| strategy: | |
| matrix: | |
| python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] | |
| steps: | |
| - name: Skip heavy Python CI for non-impacting changes | |
| if: ${{ needs.changes.outputs.skip_heavy == 'true' }} | |
| run: echo "Skipping Python ${{ matrix.python-version }} import check because only non-impacting files changed." | |
| - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| with: | |
| fetch-depth: 1 | |
| - name: Set up Python ${{ matrix.python-version }} | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| - name: Install posthog | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| run: pip install . | |
| - name: Check import produces no warnings | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| run: python -W error -c "import posthog" | |
| openfeature-provider: | |
| name: OpenFeature provider Python ${{ matrix.python-version }} | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: ${{ always() && needs.changes.outputs.skip_heavy != 'true' }} | |
| strategy: | |
| matrix: | |
| python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] | |
| steps: | |
| - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 | |
| with: | |
| fetch-depth: 1 | |
| - name: Set up Python ${{ matrix.python-version }} | |
| uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 | |
| with: | |
| enable-cache: true | |
| # openfeature-provider-posthog is a uv workspace member (declared in | |
| # the root pyproject), so commands are scoped to it with --package and | |
| # builds are pinned to its own dist/ with --out-dir. | |
| - name: Install provider dependencies | |
| shell: bash | |
| working-directory: openfeature-provider | |
| run: | | |
| UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --package openfeature-provider-posthog --extra dev | |
| - name: Run provider tests | |
| working-directory: openfeature-provider | |
| run: | | |
| uv run --package openfeature-provider-posthog pytest --verbose | |
| - name: Lint and type-check provider | |
| working-directory: openfeature-provider | |
| run: | | |
| uv run --package openfeature-provider-posthog ruff format --check . | |
| uv run --package openfeature-provider-posthog ruff check . | |
| uv run --package openfeature-provider-posthog mypy . | |
| - name: Build and verify provider distribution | |
| working-directory: openfeature-provider | |
| run: | | |
| uv build --package openfeature-provider-posthog --out-dir dist | |
| uv run --with twine twine check dist/* | |
| - name: Smoke test built wheel in a clean environment | |
| working-directory: openfeature-provider | |
| shell: bash | |
| run: | | |
| uv venv /tmp/of-smoke | |
| uv pip install --python /tmp/of-smoke/bin/python dist/*.whl | |
| /tmp/of-smoke/bin/python -c "from openfeature.contrib.provider.posthog import PostHogProvider; print(PostHogProvider)" | |
| django5-integration: | |
| name: Django 5 integration tests | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: ${{ always() }} | |
| steps: | |
| - name: Skip heavy Python CI for non-impacting changes | |
| if: ${{ needs.changes.outputs.skip_heavy == 'true' }} | |
| run: echo "Skipping Django 5 integration tests because only non-impacting files changed." | |
| - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| with: | |
| fetch-depth: 1 | |
| - name: Set up Python 3.12 | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 | |
| with: | |
| python-version: 3.12 | |
| - name: Install uv | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 | |
| with: | |
| enable-cache: true | |
| - name: Install Django 5 test project dependencies | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| shell: bash | |
| working-directory: integration_tests/django5 | |
| run: | | |
| UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync | |
| - name: Run Django 5 middleware integration tests | |
| if: ${{ needs.changes.outputs.skip_heavy != 'true' }} | |
| working-directory: integration_tests/django5 | |
| run: | | |
| uv run pytest test_middleware.py test_exception_capture.py --verbose |