This is an automated email from the ASF dual-hosted git repository. potiuk pushed a commit to branch selective-checks-scoped-branches in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 1b4b3529ded813048db3a7e25624cc150804aabd Author: Jarek Potiuk <[email protected]> AuthorDate: Sun Oct 25 19:58:30 2026 +0100 Run only the component's tests on chart and airflow-ctl release branches The chart/ and airflow-ctl/ release branches only ever get backports of the Helm chart or of airflow-ctl, yet their CI selected tests from the changed files like main does: a backport that touched shared files ran the whole core and provider matrix, and helm tests, gated on main, did not run on a chart branch whose breeze defaults say otherwise. New test jobs default to not running there, so that a job added on main does not start running on those branches unnoticed. Generated-by: Claude Opus 5 --- .github/workflows/additional-prod-image-tests.yml | 50 ++++++-- .github/workflows/basic-tests.yml | 8 ++ .github/workflows/ci-amd.yml | 4 + .github/workflows/ci-arm.yml | 4 + .../scheduled-legacy-prod-image-canary.yml | 1 + dev/breeze/doc/ci/04_selective_checks.md | 24 +++- .../src/airflow_breeze/utils/selective_checks.py | 123 +++++++++++++++++- .../src/airflow_breeze/utils/verification_plan.py | 2 + dev/breeze/tests/test_selective_checks.py | 142 +++++++++++++++++++++ 9 files changed, 345 insertions(+), 13 deletions(-) diff --git a/.github/workflows/additional-prod-image-tests.yml b/.github/workflows/additional-prod-image-tests.yml index c850b783deb..c21fe129f89 100644 --- a/.github/workflows/additional-prod-image-tests.yml +++ b/.github/workflows/additional-prod-image-tests.yml @@ -116,6 +116,12 @@ on: # yamllint disable-line rule:truthy description: "Whether to run Airflow CTL integration tests (true/false)" required: true type: string + run-generic-prod-image-tests: + description: >- + Whether to run the tests of the PROD image itself (true/false): not on the release branch of a + single component, which builds the image only for that component's tests + required: true + type: string permissions: contents: read jobs: @@ -132,7 +138,9 @@ jobs: constraints-branch: ${{ inputs.constraints-branch }} docker-cache: ${{ inputs.docker-cache }} disable-airflow-repo-cache: ${{ inputs.disable-airflow-repo-cache }} - if: inputs.default-branch == 'main' && inputs.canary-run == 'true' + if: >- + inputs.default-branch == 'main' && inputs.canary-run == 'true' && + inputs.run-generic-prod-image-tests == 'true' prod-image-extra-checks-release-branch: name: PROD image extra checks (release) @@ -147,11 +155,14 @@ jobs: constraints-branch: ${{ inputs.constraints-branch }} docker-cache: ${{ inputs.docker-cache }} disable-airflow-repo-cache: ${{ inputs.disable-airflow-repo-cache }} - if: inputs.default-branch != 'main' && inputs.canary-run == 'true' + if: >- + inputs.default-branch != 'main' && inputs.canary-run == 'true' && + inputs.run-generic-prod-image-tests == 'true' test-examples-of-prod-image-building: timeout-minutes: 60 name: "Test examples of PROD image building" + if: inputs.run-generic-prod-image-tests == 'true' runs-on: ${{ format(fromJSON(inputs.runners)[0], 4, 16) }} env: GITHUB_REPOSITORY: ${{ github.repository }} @@ -189,6 +200,7 @@ jobs: test-docker-compose-quick-start: timeout-minutes: 60 name: "Docker Compose quick start with PROD image verifying" + if: inputs.run-generic-prod-image-tests == 'true' runs-on: ${{ format(fromJSON(inputs.runners)[0], 32, 128) }} env: PYTHON_MAJOR_MINOR_VERSION: "${{ inputs.default-python-version }}" @@ -251,6 +263,7 @@ jobs: test-e2e-integration-tests-basic: name: "Test e2e integration tests with PROD image" + if: inputs.run-generic-prod-image-tests == 'true' uses: ./.github/workflows/airflow-e2e-tests.yml with: workflow-name: "Regular e2e test" @@ -269,7 +282,9 @@ jobs: default-python-version: "${{ inputs.default-python-version }}" use-uv: ${{ inputs.use-uv }} e2e_test_mode: "remote_log" - if: inputs.canary-run == 'true' || inputs.run-remote-logging-s3-e2e-tests == 'true' + if: >- + (inputs.canary-run == 'true' && inputs.run-generic-prod-image-tests == 'true') || + inputs.run-remote-logging-s3-e2e-tests == 'true' test-e2e-integration-tests-remote-log-elasticsearch: name: "Elasticsearch remote logging tests with PROD image" @@ -281,7 +296,9 @@ jobs: default-python-version: "${{ inputs.default-python-version }}" use-uv: ${{ inputs.use-uv }} e2e_test_mode: "remote_log_elasticsearch" - if: inputs.canary-run == 'true' || inputs.run-remote-logging-elasticsearch-e2e-tests == 'true' + if: >- + (inputs.canary-run == 'true' && inputs.run-generic-prod-image-tests == 'true') || + inputs.run-remote-logging-elasticsearch-e2e-tests == 'true' test-e2e-integration-tests-remote-log-opensearch: name: "OpenSearch remote logging tests with PROD image" @@ -293,10 +310,13 @@ jobs: default-python-version: "${{ inputs.default-python-version }}" use-uv: ${{ inputs.use-uv }} e2e_test_mode: "remote_log_opensearch" - if: inputs.canary-run == 'true' || inputs.run-remote-logging-opensearch-e2e-tests == 'true' + if: >- + (inputs.canary-run == 'true' && inputs.run-generic-prod-image-tests == 'true') || + inputs.run-remote-logging-opensearch-e2e-tests == 'true' test-e2e-integration-tests-xcom-object-storage: name: "XCom object storage backend tests with PROD image" + if: inputs.run-generic-prod-image-tests == 'true' uses: ./.github/workflows/airflow-e2e-tests.yml with: workflow-name: "XCom object storage backend e2e test" @@ -316,7 +336,9 @@ jobs: default-python-version: "${{ inputs.default-python-version }}" use-uv: ${{ inputs.use-uv }} e2e_test_mode: "event_driven" - if: inputs.canary-run == 'true' || inputs.run-event-driven-e2e-tests == 'true' + if: >- + (inputs.canary-run == 'true' && inputs.run-generic-prod-image-tests == 'true') || + inputs.run-event-driven-e2e-tests == 'true' test-e2e-integration-tests-java-sdk: name: "Java SDK e2e tests with PROD image" @@ -329,7 +351,9 @@ jobs: use-uv: ${{ inputs.use-uv }} e2e_test_mode: "java_sdk" java-sdk-version: "${{ inputs.java-sdk-version }}" - if: inputs.canary-run == 'true' || inputs.run-java-sdk-e2e-tests == 'true' + if: >- + (inputs.canary-run == 'true' && inputs.run-generic-prod-image-tests == 'true') || + inputs.run-java-sdk-e2e-tests == 'true' test-e2e-integration-tests-go-sdk: name: "Go SDK e2e tests with PROD image" uses: ./.github/workflows/airflow-e2e-tests.yml @@ -340,7 +364,9 @@ jobs: default-python-version: "${{ inputs.default-python-version }}" use-uv: ${{ inputs.use-uv }} e2e_test_mode: "go_sdk" - if: inputs.canary-run == 'true' || inputs.run-go-sdk-e2e-tests == 'true' + if: >- + (inputs.canary-run == 'true' && inputs.run-generic-prod-image-tests == 'true') || + inputs.run-go-sdk-e2e-tests == 'true' test-e2e-integration-tests-ts-sdk: name: "TypeScript SDK e2e tests with PROD image" @@ -352,7 +378,9 @@ jobs: default-python-version: "${{ inputs.default-python-version }}" use-uv: ${{ inputs.use-uv }} e2e_test_mode: "ts_sdk" - if: inputs.canary-run == 'true' || inputs.run-ts-sdk-e2e-tests == 'true' + if: >- + (inputs.canary-run == 'true' && inputs.run-generic-prod-image-tests == 'true') || + inputs.run-ts-sdk-e2e-tests == 'true' test-e2e-integration-tests-openlineage: name: "OpenLineage e2e tests with PROD image" @@ -364,7 +392,9 @@ jobs: default-python-version: "${{ inputs.default-python-version }}" use-uv: ${{ inputs.use-uv }} e2e_test_mode: "openlineage" - if: inputs.canary-run == 'true' || inputs.run-openlineage-e2e-tests == 'true' + if: >- + (inputs.canary-run == 'true' && inputs.run-generic-prod-image-tests == 'true') || + inputs.run-openlineage-e2e-tests == 'true' # Older-Airflow compatibility: costly, so it does not run on every OpenLineage PR — only on canary # (scheduled / main) or when a maintainer sets the "full tests needed" label (both folded into the diff --git a/.github/workflows/basic-tests.yml b/.github/workflows/basic-tests.yml index 1ecf344ef6c..944315d8e83 100644 --- a/.github/workflows/basic-tests.yml +++ b/.github/workflows/basic-tests.yml @@ -49,6 +49,12 @@ on: # yamllint disable-line rule:truthy description: "Whether to run scripts tests (true/false)" required: true type: string + run-generic-basic-tests: + description: >- + Whether to run the standalone Airflow and shared distributions tests (true/false): not on the + release branch of a single component + required: true + type: string basic-checks-only: description: "Whether to run only basic checks (true/false)" required: true @@ -137,6 +143,7 @@ jobs: tests-shared-distributions: timeout-minutes: 10 name: Shared distributions tests + if: inputs.run-generic-basic-tests == 'true' env: # Without a pin uv picks the runner's system Python, which can be older than Airflow supports. UV_PYTHON: "${{ inputs.default-python-version }}" @@ -394,6 +401,7 @@ jobs: test-airflow-standalone: timeout-minutes: 30 name: "Test Airflow standalone commands" + if: inputs.run-generic-basic-tests == 'true' runs-on: ${{ format(fromJSON(inputs.runners)[0], 4, 16) }} env: AIRFLOW_HOME: ~/airflow diff --git a/.github/workflows/ci-amd.yml b/.github/workflows/ci-amd.yml index 49f08dbd367..a80606f666b 100644 --- a/.github/workflows/ci-amd.yml +++ b/.github/workflows/ci-amd.yml @@ -324,6 +324,8 @@ jobs: run-agent-framework-tests: ${{ steps.selective-checks.outputs.run-agent-framework-tests }} run-airflow-ctl-tests: ${{ steps.selective-checks.outputs.run-airflow-ctl-tests }} run-airflow-ctl-integration-tests: ${{ steps.selective-checks.outputs.run-airflow-ctl-integration-tests }} + run-generic-basic-tests: ${{ steps.selective-checks.outputs.run-generic-basic-tests }} + run-generic-prod-image-tests: ${{ steps.selective-checks.outputs.run-generic-prod-image-tests }} run-amazon-tests: ${{ steps.selective-checks.outputs.run-amazon-tests }} run-api-codegen: ${{ steps.selective-checks.outputs.run-api-codegen }} run-api-tests: ${{ steps.selective-checks.outputs.run-api-tests }} @@ -492,6 +494,7 @@ jobs: canary-run: ${{needs.build-info.outputs.canary-run}} run-breeze-integration-tests: ${{needs.build-info.outputs.run-breeze-integration-tests}} run-scripts-tests: ${{needs.build-info.outputs.run-scripts-tests}} + run-generic-basic-tests: ${{needs.build-info.outputs.run-generic-basic-tests}} latest-versions-only: ${{needs.build-info.outputs.latest-versions-only}} use-uv: ${{needs.build-info.outputs.use-uv}} platform: ${{ needs.build-info.outputs.platform }} @@ -1164,6 +1167,7 @@ jobs: use-uv: ${{ needs.build-info.outputs.use-uv }} run-ui-e2e-tests: ${{ needs.build-info.outputs.run-ui-e2e-tests }} run-airflow-ctl-integration-tests: ${{ needs.build-info.outputs.run-airflow-ctl-integration-tests }} + run-generic-prod-image-tests: ${{ needs.build-info.outputs.run-generic-prod-image-tests }} if: needs.build-info.outputs.prod-image-build == 'true' tests-kubernetes: diff --git a/.github/workflows/ci-arm.yml b/.github/workflows/ci-arm.yml index e6299a05ece..dd6c85c035e 100644 --- a/.github/workflows/ci-arm.yml +++ b/.github/workflows/ci-arm.yml @@ -206,6 +206,8 @@ jobs: run-agent-framework-tests: ${{ steps.selective-checks.outputs.run-agent-framework-tests }} run-airflow-ctl-tests: ${{ steps.selective-checks.outputs.run-airflow-ctl-tests }} run-airflow-ctl-integration-tests: ${{ steps.selective-checks.outputs.run-airflow-ctl-integration-tests }} + run-generic-basic-tests: ${{ steps.selective-checks.outputs.run-generic-basic-tests }} + run-generic-prod-image-tests: ${{ steps.selective-checks.outputs.run-generic-prod-image-tests }} run-amazon-tests: ${{ steps.selective-checks.outputs.run-amazon-tests }} run-api-codegen: ${{ steps.selective-checks.outputs.run-api-codegen }} run-api-tests: ${{ steps.selective-checks.outputs.run-api-tests }} @@ -374,6 +376,7 @@ jobs: canary-run: ${{needs.build-info.outputs.canary-run}} run-breeze-integration-tests: ${{needs.build-info.outputs.run-breeze-integration-tests}} run-scripts-tests: ${{needs.build-info.outputs.run-scripts-tests}} + run-generic-basic-tests: ${{needs.build-info.outputs.run-generic-basic-tests}} latest-versions-only: ${{needs.build-info.outputs.latest-versions-only}} use-uv: ${{needs.build-info.outputs.use-uv}} platform: ${{ needs.build-info.outputs.platform }} @@ -1046,6 +1049,7 @@ jobs: use-uv: ${{ needs.build-info.outputs.use-uv }} run-ui-e2e-tests: ${{ needs.build-info.outputs.run-ui-e2e-tests }} run-airflow-ctl-integration-tests: ${{ needs.build-info.outputs.run-airflow-ctl-integration-tests }} + run-generic-prod-image-tests: ${{ needs.build-info.outputs.run-generic-prod-image-tests }} if: needs.build-info.outputs.prod-image-build == 'true' tests-kubernetes: diff --git a/.github/workflows/scheduled-legacy-prod-image-canary.yml b/.github/workflows/scheduled-legacy-prod-image-canary.yml index 706baf19430..ad395431d0f 100644 --- a/.github/workflows/scheduled-legacy-prod-image-canary.yml +++ b/.github/workflows/scheduled-legacy-prod-image-canary.yml @@ -239,6 +239,7 @@ jobs: use-uv: ${{ needs.build-info.outputs.use-uv }} run-ui-e2e-tests: ${{ needs.build-info.outputs.run-ui-e2e-tests }} run-airflow-ctl-integration-tests: ${{ needs.build-info.outputs.run-airflow-ctl-integration-tests }} + run-generic-prod-image-tests: "true" tests-kubernetes: name: "Kubernetes tests" diff --git a/dev/breeze/doc/ci/04_selective_checks.md b/dev/breeze/doc/ci/04_selective_checks.md index d15ff81dd5e..15509596127 100644 --- a/dev/breeze/doc/ci/04_selective_checks.md +++ b/dev/breeze/doc/ci/04_selective_checks.md @@ -204,6 +204,8 @@ turned on): ```mermaid flowchart TD + CB{target branch chart/... or airflow-ctl/...?} -->|yes| COMP[only that component's tests ON,<br/>whatever changed; see below] + CB -->|no| FTN FTN{full_tests_needed?} -->|yes| ALL[every run_* area = ON<br/>all versions, all test types] FTN -->|no| SEL[selective: gate each area on its file group] SEL --> RUT{run_unit_tests?} @@ -217,7 +219,22 @@ flowchart TD PROD --> IMG ``` -`*` Helm tests only run on the `main` branch. +`*` Helm tests only run on the `main` branch and on `chart/` branches. + +The release branches of a single component (`chart/vX-Yx-test|stable`, `airflow-ctl/vX-Y-test|stable`) +only ever get backports of that component, so their runs are scoped to it. The target branch comes from +the GitHub context (`base_ref` for a pull request, `ref_name` for a push or a dispatched run) and must +match one of those names: a feature branch named `chart/...` is not a component branch, and neither is +any push in a fork, which is selected against `main`. On such a branch every +output decorated with `_scoped_to_component_branch` is off, except those `COMPONENT_BRANCH_OUTPUTS` lists +for the component: on `chart/`, Helm and Kubernetes tests always run (kustomize overlays as usual) with +the CI and PROD images they need; on `airflow-ctl/`, the airflow-ctl tests and its integration tests run, +with both images. The PROD image is built for those tests only, so `run-generic-prod-image-tests` (Docker +Compose, image-building examples, basic and canary end-to-end tests) is off, and so is +`run-generic-basic-tests` (the standalone Airflow and shared distributions tests). Provider tests are skipped, and docs, when they changed, build only the component's +package. Static checks, scripts and Breeze integration tests, and the CodeQL scans run as usual. A test +in `test_selective_checks.py` fails when a new `run_*` output is neither scoped nor listed as a general +check, so a new job does not start running on these branches by accident. ### Stage 3 — choose which test types @@ -485,6 +502,9 @@ together using `pytest-xdist` (pytest-xdist distributes the tests among parallel * `Python scans`, `Javascript scans`, `API tests/codegen`, `UI`, `WWW`, `Kubernetes` tests and `DOC builds` are enabled if any of the relevant files have been changed. * `Helm` tests are run only if relevant files have been changed and if current branch is `main`. +* On a component's release branch (`chart/...`, `airflow-ctl/...`) only that component's tests run, always, + with the images they need; other tests do not run, provider tests are skipped, and docs build only the + component's package. Static checks still run. See [Stage 2](#stage-2--decide-which-jobs-run). * If no Source files are changed - no tests are run and no further rules below are checked. * `CI Image building` is enabled if either test are run, docs are build. * `PROD Image building` is enabled when kubernetes tests are run. @@ -676,6 +696,8 @@ GitHub Actions to pass the list of parameters to a command to execute | run-amazon-tests | Whether Amazon tests should be run ("true"/"false") | true | | | run-api-codegen | Whether "api-codegen" are needed to run ("true"/"false") | true | | | run-api-tests | Whether "api-tests" are needed to run ("true"/"false") | true | | +| run-generic-prod-image-tests | Whether the PROD image's own tests run (Docker Compose, examples, basic e2e): off on component branches | true | | +| run-generic-basic-tests | Whether the standalone Airflow and shared distributions tests run: off on component branches | true | | | run-helm-tests | Whether Helm tests are needed to run ("true"/"false") | true | | | run-kubernetes-tests | Whether Kubernetes tests should be run ("true"/"false") | true | | | run-mypy | Whether mypy check is supposed to run in this build | true | | diff --git a/dev/breeze/src/airflow_breeze/utils/selective_checks.py b/dev/breeze/src/airflow_breeze/utils/selective_checks.py index 2d420e8ef90..7e0fdb841e5 100644 --- a/dev/breeze/src/airflow_breeze/utils/selective_checks.py +++ b/dev/breeze/src/airflow_breeze/utils/selective_checks.py @@ -25,8 +25,9 @@ import re import sys import tomllib from collections import defaultdict +from collections.abc import Callable from enum import Enum, auto -from functools import cached_property +from functools import cached_property, wraps from pathlib import Path from typing import Any, TypeVar @@ -896,6 +897,56 @@ def _get_test_list_as_json(list_of_list_of_types: list[list[str]]) -> list[dict[ ] +# The release branches of a single component (the Helm chart, airflow-ctl) only ever get backports of that +# component, so CI there runs that component's tests, whatever changed, and nothing else. Per branch prefix, +# the outputs decorated with `_scoped_to_component_branch` that run: True forces one on, None leaves it to +# its usual computation, and an output missing from the table is off. Static checks and docs still run. +# The PROD image is built for those tests alone: `run_generic_prod_image_tests` stays off. +COMPONENT_BRANCH_OUTPUTS: dict[str, dict[str, bool | None]] = { + "chart": { + "run_helm_tests": True, + "run_kubernetes_tests": True, + "run_kustomize_overlays_tests": None, + # Helm unit tests run in the CI image; Kubernetes tests deploy the PROD image. + "ci_image_build": True, + "prod_image_build": True, + }, + "airflow-ctl": { + "run_airflow_ctl_tests": True, + "run_airflow_ctl_integration_tests": True, + # The airflow-ctl integration tests run against the PROD image, built from the CI image. + "ci_image_build": True, + "prod_image_build": True, + }, +} +# The documentation built on a component's branch, when documentation changed. +COMPONENT_BRANCH_DOCS = {"chart": "helm-chart", "airflow-ctl": "apache-airflow-ctl"} +# The release branches of each component, as in the pull_request branch filters of ci-amd.yml. A feature +# branch named after a component, say chart/fix-ingress, is not one of them. +COMPONENT_BRANCH_PATTERNS = { + "chart": re.compile(r"^chart/v\d+-\d+x-(test|stable)$"), + "airflow-ctl": re.compile(r"^airflow-ctl/v\d+-\d+-(test|stable)$"), +} + + +def _scoped_to_component_branch(func: Callable[[SelectiveChecks], bool]) -> Callable[[SelectiveChecks], bool]: + """Restrict an output deciding whether a test job runs to the component of a component branch.""" + + @wraps(func) + def wrapper(self: SelectiveChecks) -> bool: + component = self._component_branch + if component is None: + return func(self) + outputs = COMPONENT_BRANCH_OUTPUTS[component] + if func.__name__ not in outputs: + return False + forced = outputs[func.__name__] + return func(self) if forced is None else forced + + wrapper.scoped_to_component_branch = True # type: ignore[attr-defined] + return wrapper + + class SelectiveChecks: __HASHABLE_FIELDS = { "_files", @@ -998,6 +1049,26 @@ class SelectiveChecks: def latest_versions_only(self) -> bool: return LATEST_VERSIONS_ONLY_LABEL in self._pr_labels + @cached_property + def _target_branch(self) -> str: + """The branch a pull request targets, or the branch a push or a dispatched run is on.""" + return self._github_context_dict.get("base_ref") or self._github_context_dict.get("ref_name") or "" + + @cached_property + def _component_branch(self) -> str | None: + """The component in COMPONENT_BRANCH_OUTPUTS whose release branch the run targets, if any.""" + # A push in a fork is selected against apache/airflow's main, whatever its branch is called. + if self._fork_push: + return None + return next( + ( + component + for component, pattern in COMPONENT_BRANCH_PATTERNS.items() + if pattern.match(self._target_branch) + ), + None, + ) + @cached_property def default_python_version(self) -> str: return ( @@ -1256,6 +1327,7 @@ class SelectiveChecks: return False @cached_property + @_scoped_to_component_branch def run_mypy_providers(self) -> bool: # Non-provider mypy checks run as part of regular static checks (prek hooks). # Only provider mypy needs a separate CI job (requires the CI Docker image with breeze). @@ -1281,14 +1353,17 @@ class SelectiveChecks: return self._should_be_run(FileGroupForCi.JAVASCRIPT_PRODUCTION_FILES) @cached_property + @_scoped_to_component_branch def run_api_tests(self) -> bool: return self._should_be_run(FileGroupForCi.API_FILES) @cached_property + @_scoped_to_component_branch def run_ol_tests(self) -> bool: return self._should_be_run(FileGroupForCi.ASSET_FILES) @cached_property + @_scoped_to_component_branch def run_api_codegen(self) -> bool: return self._should_be_run(FileGroupForCi.API_CODEGEN_FILES) @@ -1297,10 +1372,12 @@ class SelectiveChecks: return self._should_be_run(FileGroupForCi.BREEZE_INTEGRATION_TEST_FILES) @cached_property + @_scoped_to_component_branch def run_ui_tests(self) -> bool: return self._should_be_run(FileGroupForCi.UI_FILES) @cached_property + @_scoped_to_component_branch def run_ui_e2e_tests(self) -> bool: # E2E tests should not be triggered by derived full_tests_needed (push events, # env file changes, large PRs, etc.) - only by explicit label or actual file changes. @@ -1331,40 +1408,48 @@ class SelectiveChecks: return False @cached_property + @_scoped_to_component_branch def run_remote_logging_s3_e2e_tests(self) -> bool: return self._should_be_run(FileGroupForCi.REMOTE_LOGGING_E2E_SHARED_FILES) or self._should_be_run( FileGroupForCi.REMOTE_LOGGING_E2E_S3_FILES ) @cached_property + @_scoped_to_component_branch def run_remote_logging_elasticsearch_e2e_tests(self) -> bool: return self._should_be_run(FileGroupForCi.REMOTE_LOGGING_E2E_SHARED_FILES) or self._should_be_run( FileGroupForCi.REMOTE_LOGGING_E2E_ELASTICSEARCH_FILES ) @cached_property + @_scoped_to_component_branch def run_remote_logging_opensearch_e2e_tests(self) -> bool: return self._should_be_run(FileGroupForCi.REMOTE_LOGGING_E2E_SHARED_FILES) or self._should_be_run( FileGroupForCi.REMOTE_LOGGING_E2E_OPENSEARCH_FILES ) @cached_property + @_scoped_to_component_branch def run_event_driven_e2e_tests(self) -> bool: return self._should_be_run(FileGroupForCi.EVENT_DRIVEN_E2E_FILES) @cached_property + @_scoped_to_component_branch def run_java_sdk_e2e_tests(self) -> bool: return self._should_be_run(FileGroupForCi.JAVA_SDK_E2E_FILES) @cached_property + @_scoped_to_component_branch def run_go_sdk_e2e_tests(self) -> bool: return self._should_be_run(FileGroupForCi.GO_SDK_E2E_FILES) @cached_property + @_scoped_to_component_branch def run_openlineage_e2e_tests(self) -> bool: return self._should_be_run(FileGroupForCi.OPENLINEAGE_E2E_FILES) @cached_property + @_scoped_to_component_branch def run_openlineage_e2e_compat_tests(self) -> bool: # Costly older-Airflow matrix. Like run_ui_e2e_tests it is not triggered by *derived* # full_tests_needed (pushes, env changes, large PRs) — only by canary, an explicit label, or @@ -1376,10 +1461,12 @@ class SelectiveChecks: return self._should_be_run(FileGroupForCi.OPENLINEAGE_E2E_COMPAT_FILES) @cached_property + @_scoped_to_component_branch def run_ts_sdk_e2e_tests(self) -> bool: return self._should_be_run(FileGroupForCi.TS_SDK_E2E_FILES) @cached_property + @_scoped_to_component_branch def run_amazon_tests(self) -> bool: if self.providers_test_types_list_as_strings_in_json == "[]": return False @@ -1389,16 +1476,19 @@ class SelectiveChecks: ) @cached_property + @_scoped_to_component_branch def run_task_sdk_tests(self) -> bool: return self._should_be_run(FileGroupForCi.TASK_SDK_FILES) @cached_property + @_scoped_to_component_branch def run_task_sdk_integration_tests(self) -> bool: return self._should_be_run(FileGroupForCi.TASK_SDK_FILES) or self._should_be_run( FileGroupForCi.TASK_SDK_INTEGRATION_TEST_FILES ) @cached_property + @_scoped_to_component_branch def run_agent_framework_tests(self) -> bool: # Providers are released from main only, as for skip_providers_tests. if self._default_branch != "main": @@ -1406,26 +1496,32 @@ class SelectiveChecks: return self._should_be_run(FileGroupForCi.AGENT_FRAMEWORK_FILES) @cached_property + @_scoped_to_component_branch def run_go_sdk_tests(self) -> bool: return self._should_be_run(FileGroupForCi.GO_SDK_FILES) @cached_property + @_scoped_to_component_branch def run_java_sdk_tests(self) -> bool: return self._should_be_run(FileGroupForCi.JAVA_SDK_FILES) @cached_property + @_scoped_to_component_branch def run_ts_sdk_tests(self) -> bool: return self._should_be_run(FileGroupForCi.TS_SDK_FILES) @cached_property + @_scoped_to_component_branch def run_ts_sdk_docs(self) -> bool: return self._should_be_run(FileGroupForCi.TS_SDK_DOCS_FILES) @cached_property + @_scoped_to_component_branch def run_airflow_ctl_tests(self) -> bool: return self._should_be_run(FileGroupForCi.AIRFLOW_CTL_FILES) @cached_property + @_scoped_to_component_branch def run_airflow_ctl_integration_tests(self) -> bool: return self._should_be_run(FileGroupForCi.AIRFLOW_CTL_FILES) or self._should_be_run( FileGroupForCi.AIRFLOW_CTL_INTEGRATION_TEST_FILES @@ -1436,6 +1532,7 @@ class SelectiveChecks: return self._should_be_run(FileGroupForCi.SCRIPTS_FILES) @cached_property + @_scoped_to_component_branch def run_kubernetes_tests(self) -> bool: if AREA_KUBERNETES_TESTS_LABEL in self._pr_labels: console_print( @@ -1450,10 +1547,12 @@ class SelectiveChecks: return self._should_be_run(FileGroupForCi.DOC_FILES) @cached_property + @_scoped_to_component_branch def run_helm_tests(self) -> bool: return self._should_be_run(FileGroupForCi.HELM_FILES) and self._default_branch == "main" @cached_property + @_scoped_to_component_branch def run_kustomize_overlays_tests(self) -> bool: """Gate for the kustomize-overlays smoke test CI job. @@ -1467,6 +1566,7 @@ class SelectiveChecks: return self._should_be_run(FileGroupForCi.KUSTOMIZE_OVERLAYS_FILES) and self._default_branch == "main" @cached_property + @_scoped_to_component_branch def run_unit_tests(self) -> bool: def _only_new_ui_files() -> bool: all_source_files = set( @@ -1504,6 +1604,7 @@ class SelectiveChecks: return len(self._files) > 0 and set(self._files) <= text_non_doc_files @cached_property + @_scoped_to_component_branch def ci_image_build(self) -> bool: # in case pyproject.toml changed, CI image should be built - even if no build dependencies # changes because some of our tests - those that need CI image might need to be run depending on @@ -1523,6 +1624,21 @@ class SelectiveChecks: ) @cached_property + @_scoped_to_component_branch + def run_generic_basic_tests(self) -> bool: + """The basic tests of Airflow itself that run for every change: the standalone Airflow and the + shared distributions.""" + return True + + @cached_property + @_scoped_to_component_branch + def run_generic_prod_image_tests(self) -> bool: + """The tests of the PROD image itself, run whenever it is built: Docker Compose, the image-building + examples and the basic end-to-end tests.""" + return self.prod_image_build + + @cached_property + @_scoped_to_component_branch def prod_image_build(self) -> bool: if AREA_E2E_TESTS_LABEL in self._pr_labels: console_print( @@ -1940,6 +2056,8 @@ class SelectiveChecks: _ALL_DOCS_LIST = "" if not self.docs_build: return None + if self._component_branch is not None: + return COMPONENT_BRANCH_DOCS[self._component_branch] if self._default_branch != "main": return "apache-airflow docker-stack" if self.full_tests_needed: @@ -2099,7 +2217,7 @@ class SelectiveChecks: @cached_property def skip_providers_tests(self) -> bool: - if self._default_branch != "main": + if self._default_branch != "main" or self._component_branch is not None: return True if self.full_tests_needed: return False @@ -2296,6 +2414,7 @@ class SelectiveChecks: return False @cached_property + @_scoped_to_component_branch def has_migrations(self) -> bool: return any([file.startswith("airflow-core/src/airflow/migrations/") for file in self._files]) diff --git a/dev/breeze/src/airflow_breeze/utils/verification_plan.py b/dev/breeze/src/airflow_breeze/utils/verification_plan.py index aa3c32449e2..5df301e2d40 100644 --- a/dev/breeze/src/airflow_breeze/utils/verification_plan.py +++ b/dev/breeze/src/airflow_breeze/utils/verification_plan.py @@ -98,6 +98,8 @@ NOT_RUNNABLE_LOCALLY: frozenset[str] = frozenset( "run_openlineage_e2e_tests", "run_openlineage_e2e_compat_tests", "run_ts_sdk_e2e_tests", + "run_generic_prod_image_tests", + "run_generic_basic_tests", } ) diff --git a/dev/breeze/tests/test_selective_checks.py b/dev/breeze/tests/test_selective_checks.py index 90ee9560f0d..efb8baa40a1 100644 --- a/dev/breeze/tests/test_selective_checks.py +++ b/dev/breeze/tests/test_selective_checks.py @@ -48,6 +48,7 @@ from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH from airflow_breeze.utils.provider_dependencies import get_provider_dependencies from airflow_breeze.utils.selective_checks import ( ALL_CI_SELECTIVE_TEST_TYPES, + COMPONENT_BRANCH_OUTPUTS, LONG_RUNNING_TEST_PROVIDERS, PROVIDERS_WITH_PROCESS_GLOBAL_TEST_SIDE_EFFECTS, SelectiveChecks, @@ -5316,3 +5317,144 @@ def test_self_hosted_label_ignored_when_reviews_cannot_be_read( pr_labels=("use self-hosted runners",), ) assert selective_checks.runner_type == expected + + +# The outputs that stay as computed on the branch of a single component: they are general checks rather +# than another component's tests. Any other new `run_*` output must be scoped, so that it does not run there. +GENERAL_CHECKS = { + "run_breeze_integration_tests", + "run_javascript_scans", + "run_python_scans", + "run_scripts_tests", + "run_system_tests", # follows run_unit_tests +} + + +def test_every_test_output_is_scoped_to_component_branches(): + names = [ + name for name in dir(SelectiveChecks) if name.startswith("run_") or name.endswith("_image_build") + ] + unscoped = [ + name + for name in names + if name not in GENERAL_CHECKS + and not getattr(getattr(SelectiveChecks, name).func, "scoped_to_component_branch", False) + ] + assert unscoped == [] + for outputs in COMPONENT_BRANCH_OUTPUTS.values(): + assert set(outputs) <= set(names) + + +def _component_branch_checks(context: dict, files: tuple[str, ...]) -> SelectiveChecks: + return SelectiveChecks( + files=files, + commit_ref=NEUTRAL_COMMIT, + github_event=GithubEvents.PULL_REQUEST if "base_ref" in context else GithubEvents.PUSH, + github_context_dict=context, + ) + + [email protected]( + ("context", "expected_on", "expected_docs"), + [ + pytest.param( + {"base_ref": "chart/v1-2x-test"}, + {"run_helm_tests", "run_kubernetes_tests", "ci_image_build", "prod_image_build"}, + "helm-chart", + id="pull-request-to-chart-branch", + ), + pytest.param( + {"ref_name": "chart/v1-2x-test"}, + # A push runs in full, so the kustomize overlays, selected as usual, run too. + { + "run_helm_tests", + "run_kubernetes_tests", + "run_kustomize_overlays_tests", + "ci_image_build", + "prod_image_build", + }, + "helm-chart", + id="push-to-chart-branch", + ), + pytest.param( + {"base_ref": "airflow-ctl/v0-1-test"}, + { + "run_airflow_ctl_tests", + "run_airflow_ctl_integration_tests", + "ci_image_build", + "prod_image_build", + }, + "apache-airflow-ctl", + id="pull-request-to-airflow-ctl-branch", + ), + ], +) +def test_component_branch_runs_only_its_component(context, expected_on, expected_docs): + # A change that would run nearly everything on main. + checks = _component_branch_checks( + context, ("airflow-core/src/airflow/models/dag.py", "airflow-core/docs/index.rst") + ) + scoped = { + name + for name in dir(SelectiveChecks) + if (name.startswith("run_") or name.endswith("_image_build")) and name not in GENERAL_CHECKS + } + + assert {name for name in scoped if getattr(checks, name)} == expected_on + assert checks.skip_providers_tests is True + assert checks.has_migrations is False + assert checks.docs_list_as_string == expected_docs + + [email protected]( + ("files", "expected"), + [ + pytest.param(("chart/kustomize-overlays/base/kustomization.yaml",), True, id="overlay-changed"), + pytest.param(("chart/values.yaml",), False, id="overlay-unchanged"), + ], +) +def test_chart_branch_keeps_the_kustomize_overlay_selection(files, expected): + checks = _component_branch_checks({"base_ref": "chart/v1-2x-test"}, files) + + assert checks.run_kustomize_overlays_tests is expected + + [email protected]( + ("context", "fork_push", "expected"), + [ + pytest.param({"base_ref": "chart/v1-2x-stable"}, False, "chart", id="chart-stable-branch"), + pytest.param({"base_ref": "airflow-ctl/v0-1-stable"}, False, "airflow-ctl", id="airflow-ctl-stable"), + pytest.param({"base_ref": "v3-4-test"}, False, None, id="airflow-release-branch"), + pytest.param({"base_ref": "main"}, False, None, id="main"), + # A feature branch named after a component is not its release branch. + pytest.param({"ref_name": "chart/fix-ingress"}, False, None, id="chart-feature-branch"), + # A fork push is selected against main, whatever its branch is called. + pytest.param({"ref_name": "chart/v1-2x-test"}, True, None, id="fork-push"), + ], +) +def test_component_branch_detection(context, fork_push, expected): + checks = SelectiveChecks( + files=("airflow-core/src/airflow/models/dag.py",), + commit_ref=NEUTRAL_COMMIT, + github_event=GithubEvents.PUSH if fork_push else GithubEvents.PULL_REQUEST, + github_context_dict=context, + fork_push=fork_push, + ) + + assert checks._component_branch == expected + + [email protected]( + ("context", "expected"), + [ + pytest.param({"base_ref": "main"}, True, id="main"), + # The PROD image is built there for the Kubernetes tests alone. + pytest.param({"base_ref": "chart/v1-2x-test"}, False, id="chart-branch"), + ], +) +def test_generic_tests_run_outside_component_branches(context, expected): + checks = _component_branch_checks(context, ("kubernetes-tests/tests/kubernetes_tests/test_base.py",)) + + assert checks.prod_image_build is True + assert checks.run_generic_prod_image_tests is expected + assert checks.run_generic_basic_tests is expected
