This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new d09396754c9 [v3-4-test] Run only the component's tests on chart and 
airflow-ctl release branches (#75621) (#75624)
d09396754c9 is described below

commit d09396754c99f801ba7fd4592589c20a91f141ea
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Oct 25 21:12:44 2026 +0100

    [v3-4-test] Run only the component's tests on chart and airflow-ctl release 
branches (#75621) (#75624)
    
    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.
    (cherry picked from commit 47296ef589226ec436543b0f8b56c2e5079ec54a)
    
    
    Generated-by: Claude Opus 5
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 .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 d7fa2041ca8..808bed1f7ff 100644
--- a/dev/breeze/src/airflow_breeze/utils/verification_plan.py
+++ b/dev/breeze/src/airflow_breeze/utils/verification_plan.py
@@ -93,6 +93,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

Reply via email to