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

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new f92b7c33851 Run full CI on release branches on a schedule instead of 
every push (#75420)
f92b7c33851 is described below

commit f92b7c33851d2faf9832ed6cd71f8516bfa725a2
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu Oct 22 23:03:15 2026 +0200

    Run full CI on release branches on a schedule instead of every push (#75420)
    
    * Run full CI on release branches on a schedule instead of every push
    
    While a release is prepared, backports land on vX-Y-test in bursts and
    every push started a full AMD and ARM run. In the week of 15-21 October
    v3-4-test triggered 177 such runs; 133 of them were cancelled by the
    next push after already using hundreds of runner-minutes each, which
    made release-branch pushes one of the largest single consumers of the
    ASF's shared runners. Each backport PR has already run CI against the
    branch, so the branch needs a full run of its current HEAD a few times
    a day rather than one per merged commit.
    
    Release managers still need quick feedback on a broken backport, so a
    push still runs the tests for what changed since the last successful
    run of the branch. Testing from the last success rather than from the
    pushed commit alone means changes whose runs failed or were cancelled
    by a newer push are not left untested.
    
    Scheduled canaries that have nothing new to test - no commit since the
    last successful full run - only repeat that run, so they now finish
    without building anything.
    
    Generated-by: Claude Opus 5
    
    * Address review: fail open, keep docs publishing on main, check full push 
range
    
    - check-run-needed lookups fail open (push: all tests, canary: runs), and
      notify-slack posts unless the canary was a no-op.
    - Docs publishing steps of a canary run only on main: release branches build
      a subset of the docs, which `aws s3 sync --delete` would publish over the
      docs bucket. release-branch-ci.yml skips branches this is not backported 
to.
    - Basic static checks of a push cover the files changed since the last
      tested commit, as its test selection does.
    - List pushed changes with --no-renames, as diff-tree does.
    - A dispatched full run upgrades to newer dependencies, as a scheduled one.
    
    Generated-by: Claude Opus 5
---
 .github/workflows/basic-tests.yml                  |  35 ++++-
 .github/workflows/ci-amd.yml                       | 122 ++++++++++++++--
 .github/workflows/ci-arm.yml                       | 126 +++++++++++++---
 .github/workflows/ci-image-checks.yml              |  23 ++-
 .github/workflows/refresh-image-registry-cache.yml |  11 +-
 .github/workflows/release-branch-ci.yml            | 161 +++++++++++++++++++++
 dev/README_AIRFLOW3_DEV.md                         |   9 +-
 dev/README_RELEASE_AIRFLOW.md                      |  59 +++++++-
 dev/README_RELEASE_AIRFLOWCTL.md                   |  12 +-
 dev/breeze/doc/ci/04_selective_checks.md           |  49 ++++---
 dev/breeze/doc/ci/05_workflows.md                  |  66 ++++++++-
 .../doc/images/output_ci_selective-check.svg       |  54 ++++---
 .../doc/images/output_ci_selective-check.txt       |   2 +-
 .../src/airflow_breeze/commands/ci_commands.py     |  60 +++++++-
 .../airflow_breeze/commands/ci_commands_config.py  |   2 +
 .../src/airflow_breeze/utils/selective_checks.py   |  19 ++-
 dev/breeze/tests/test_ci_commands.py               | 114 +++++++++++++++
 dev/breeze/tests/test_pr_info.py                   |   2 +-
 dev/breeze/tests/test_selective_checks.py          |  33 +++++
 scripts/ci/prek/check_ci_workflows_in_sync.py      |  27 +++-
 20 files changed, 871 insertions(+), 115 deletions(-)

diff --git a/.github/workflows/basic-tests.yml 
b/.github/workflows/basic-tests.yml
index cce20ba17aa..4212f4bd872 100644
--- a/.github/workflows/basic-tests.yml
+++ b/.github/workflows/basic-tests.yml
@@ -76,6 +76,11 @@ on:  # yamllint disable-line rule:truthy
         description: 'Platform for the build - linux/amd64 or linux/arm64'
         required: true
         type: string
+      push-base-commit:
+        description: "For a push: the last successfully tested commit of the 
branch (empty otherwise)"
+        required: false
+        type: string
+        default: ""
 permissions:
   contents: read
 jobs:
@@ -274,11 +279,35 @@ jobs:
           ref: ${{ github.sha }}
           fetch-depth: 2
           persist-credentials: false
+      - name: "Fetch the last tested push commit"
+        if: inputs.push-base-commit != ''
+        shell: bash
+        env:
+          PUSH_BASE_COMMIT: ${{ inputs.push-base-commit }}
+        run: |
+          git fetch --no-tags --depth=1 origin "${PUSH_BASE_COMMIT}" \
+            || echo "::warning::Could not fetch ${PUSH_BASE_COMMIT}, all files 
will be checked"
       - name: "Static checks: basic checks only"
-        run: >
-          prek --show-diff-on-failure --color always
-          --from-ref "${{ github.sha }}"^ --to-ref "${{ github.sha }}"
+        # A push is checked from the last tested commit of the branch, like 
its test selection: the
+        # commits of earlier failed or cancelled push runs are checked again. 
`--from-ref` needs the
+        # merge base, which the shallow fetch does not have, so the changed 
files are listed here.
+        shell: bash
+        run: |
+          set -euo pipefail
+          prek_args=(--show-diff-on-failure --color always)
+          if [[ -z "${PUSH_BASE_COMMIT}" ]]; then
+            prek "${prek_args[@]}" --from-ref "${GITHUB_SHA}^" --to-ref 
"${GITHUB_SHA}"
+          elif git cat-file -e "${PUSH_BASE_COMMIT}^{commit}"; then
+            mapfile -t files < <(git diff --name-only --no-renames 
--diff-filter=d \
+              "${PUSH_BASE_COMMIT}" "${GITHUB_SHA}")
+            if (( ${#files[@]} > 0 )); then
+              prek "${prek_args[@]}" --files "${files[@]}"
+            fi
+          else
+            prek "${prek_args[@]}" --all-files
+          fi
         env:
+          PUSH_BASE_COMMIT: ${{ inputs.push-base-commit }}
           VERBOSE: "false"
           SKIP_BREEZE_PREK_HOOKS: "true"
           SKIP: ${{ inputs.skip-prek-hooks }}
diff --git a/.github/workflows/ci-amd.yml b/.github/workflows/ci-amd.yml
index abb0aca0d84..87c734b11e5 100644
--- a/.github/workflows/ci-amd.yml
+++ b/.github/workflows/ci-amd.yml
@@ -16,8 +16,8 @@
 # under the License.
 #
 ---
-# AMD CI workflow. Triggered on every PR and on push to release branches.
-# Companion to `ci-arm.yml`, which runs the same pipeline on ARM as the
+# AMD CI workflow. Triggered on every PR, on the canary cron, and on release 
branches by
+# `release-branch-ci.yml`. Companion to `ci-arm.yml`, which runs the same 
pipeline on ARM as the
 # scheduled canary. The two workflows are physical copies of each other
 # (GitHub Actions has no cross-file YAML include), differing only in their
 # trigger surface, the hardcoded `runner-type` / `platform` values in
@@ -43,11 +43,10 @@ on:  # yamllint disable-line rule:truthy
       - airflow-ctl/v[0-9]+-[0-9]+-stable
     types: [opened, reopened, synchronize, ready_for_review]
   push:
-    # Post-merge pushes to release-prep / providers branches run on both
-    # AMD and ARM (the matching block lives in the other wrapper too —
-    # `check-ci-workflows-in-sync` enforces they stay identical). `main` is
-    # intentionally NOT in this list: main pushes stay AMD-only via
-    # `ci-amd.yml`; ARM main coverage is the cron-driven canary above.
+    # Pushes to release-prep / providers branches run only the tests needed by 
the changes since the
+    # last successful run of the branch (selected as for a PR). Full runs of 
these branches, on AMD
+    # and ARM, are dispatched by `release-branch-ci.yml`. `main` does not 
build on push; its full
+    # runs are the canary above.
     branches:
       - v[0-9]+-[0-9]+-test
       - providers-[a-z]+-?[a-z]*/v[0-9]+-[0-9]+
@@ -66,12 +65,97 @@ env:
   VERBOSE: "true"
 
 concurrency:
-  group: ci-amd-${{ github.event.pull_request.number || github.ref }}
-  cancel-in-progress: true
+  # Push runs have a group of their own per branch: a newer push cancels the 
older push run (the
+  # newer run then also tests the cancelled run's changes, see 
`check-run-needed`), and a push never
+  # cancels a full run of the branch.
+  group: >-
+    ci-amd-${{ github.event.pull_request.number || github.ref }}${{
+    github.event_name == 'push' && '-push' || '' }}
+  # A scheduled canary queues behind a run in progress instead of cancelling 
it: it may well be a
+  # manual run of the same commit, which the canary would otherwise cancel and 
then skip itself.
+  cancel-in-progress: ${{ github.event_name != 'schedule' }}
 
 jobs:
+  check-run-needed:
+    name: "Check if the run is needed"
+    # A scheduled canary is not needed when the last completed full run of 
this workflow on the
+    # branch tested the same commit and succeeded: it would only repeat that 
run. A push run tests
+    # the changes since the last successful run of this workflow on the 
branch, found here.
+    # This job runs for every event, with no steps for the others: every other 
job depends on it
+    # through `build-info`, and GitHub skips the whole dependency chain below 
a skipped job unless
+    # each job in it has a status function such as `always()` in its `if:`.
+    runs-on: ["ubuntu-slim"]
+    timeout-minutes: 5
+    permissions:
+      actions: read
+      contents: read
+    outputs:
+      run-needed: ${{ steps.schedule.outputs.run-needed || 'true' }}
+      push-base-commit: ${{ steps.push.outputs.push-base-commit }}
+    steps:
+      - name: "Find the last successful run of the branch"
+        if: github.event_name == 'push'
+        id: push
+        shell: bash
+        env:
+          GH_TOKEN: ${{ github.token }}
+          REPOSITORY: ${{ github.repository }}
+          WORKFLOW_REF: ${{ github.workflow_ref }}
+          BRANCH: ${{ github.ref_name }}
+        run: |
+          set -euo pipefail
+          workflow_path="${WORKFLOW_REF%@*}"
+          # A successful push or full run tested everything up to its commit. 
Changes after it -- from
+          # pushes whose runs failed, or were cancelled by a newer push -- are 
all tested by this run.
+          # A successful run of this same commit is passed over, so that a 
re-run tests again.
+          if ! base="$(gh api 
"repos/${REPOSITORY}/actions/workflows/${workflow_path##*/}/runs" -X GET \
+            -f branch="${BRANCH}" -f status=success -F per_page=30 \
+            --jq "[.workflow_runs[] | select((.event == \"push\" or .event == 
\"workflow_dispatch\")
+                  and .head_sha != \"${GITHUB_SHA}\")][0].head_sha // \"\"")"; 
then
+            # Fail open: a failed lookup must not skip the run.
+            echo "::warning::Could not find the last successful run of 
${BRANCH}, all tests will run"
+            base=""
+          fi
+          # With no successful run to build on, selective checks run all tests.
+          echo "push-base-commit=${base}" >> "${GITHUB_OUTPUT}"
+          echo "Testing the changes since ${base:-the start: no successful run 
yet, so all tests run}." \
+            >> "${GITHUB_STEP_SUMMARY}"
+      - name: "Check the last full run of ${{ github.sha }}"
+        if: github.event_name == 'schedule'
+        id: schedule
+        shell: bash
+        env:
+          GH_TOKEN: ${{ github.token }}
+          REPOSITORY: ${{ github.repository }}
+          WORKFLOW_REF: ${{ github.workflow_ref }}
+          BRANCH: ${{ github.ref_name }}
+        run: |
+          set -euo pipefail
+          workflow_path="${WORKFLOW_REF%@*}"
+          workflow="${workflow_path##*/}"
+          # Full runs are the scheduled canaries and manual 
`workflow_dispatch` runs; this run is
+          # still in progress, so `status=completed` leaves it out.
+          if ! last="$(gh api \
+            
"repos/${REPOSITORY}/actions/workflows/${workflow}/runs?branch=${BRANCH}&status=completed"
 \
+            -F per_page=20 -X GET \
+            --jq '[.workflow_runs[] | select(.event == "schedule" or .event == 
"workflow_dispatch")][0]
+                  | "\(.head_sha // "") \(.conclusion // "") \(.html_url // 
"")"')"; then
+            # Fail open: a canary that cannot check the last run runs, and 
reports to Slack.
+            echo "::warning::Could not find the last full run of ${BRANCH}, 
running the canary"
+            last=""
+          fi
+          read -r last_sha last_conclusion last_url <<< "${last}"
+          if [[ "${last_sha}" == "${GITHUB_SHA}" && "${last_conclusion}" == 
"success" ]]; then
+            echo "run-needed=false" >> "${GITHUB_OUTPUT}"
+            echo "No new commits since the last successful full run: 
${last_url}" >> "${GITHUB_STEP_SUMMARY}"
+          else
+            echo "run-needed=true" >> "${GITHUB_OUTPUT}"
+          fi
+
   build-info:
     name: "Build info"
+    needs: [check-run-needed]
+    if: needs.check-run-needed.outputs.run-needed == 'true'
     # At build-info stage we do not yet have outputs so we need to hard-code 
the runs-on to public runners
     runs-on: ["ubuntu-22.04"]
     outputs:
@@ -190,6 +274,16 @@ jobs:
           ref: ${{ github.sha }}
           fetch-depth: 2
           persist-credentials: false
+      - name: "Fetch the last tested push commit"
+        # Selective checks of a push diff against this commit. A two-commit 
diff only needs the
+        # commit itself, not the history in between; if it cannot be fetched, 
all tests run.
+        if: needs.check-run-needed.outputs.push-base-commit != ''
+        shell: bash
+        env:
+          PUSH_BASE_COMMIT: ${{ 
needs.check-run-needed.outputs.push-base-commit }}
+        run: |
+          git fetch --no-tags --depth=1 origin "${PUSH_BASE_COMMIT}" \
+            || echo "::warning::Could not fetch ${PUSH_BASE_COMMIT}, all tests 
will run"
       - name: "Install Breeze"
         uses: ./.github/actions/breeze
         id: breeze
@@ -231,6 +325,8 @@ jobs:
           VERBOSE: "false"
           GITHUB_CONTEXT_INPUT: "${{ runner.temp }}/github_context.json"
           PLATFORM: "linux/amd64"
+          SELECTIVE_TESTS_ON_PUSH: "true"
+          PUSH_BASE_COMMIT: ${{ 
needs.check-run-needed.outputs.push-base-commit }}
         run: breeze ci selective-check 2>> ${GITHUB_OUTPUT}
       - name: env
         run: printenv
@@ -247,7 +343,7 @@ jobs:
 
   basic-tests:
     name: "Basic tests"
-    needs: [build-info]
+    needs: [build-info, check-run-needed]
     uses: ./.github/workflows/basic-tests.yml
     with:
       runners: ${{ needs.build-info.outputs.runner-type }}
@@ -264,6 +360,7 @@ jobs:
       use-uv: ${{needs.build-info.outputs.use-uv}}
       platform: ${{ needs.build-info.outputs.platform }}
       shared-distributions-as-json: 
${{needs.build-info.outputs.shared-distributions-as-json}}
+      push-base-commit: ${{ needs.check-run-needed.outputs.push-base-commit }}
 
   build-ci-images:
     name: Build CI images
@@ -1202,7 +1299,7 @@ jobs:
       contents: write
       packages: write
     # This will fire when all the jobs from "needs" are either successful or 
skipped
-    if: always() && !failure() && !cancelled()
+    if: always() && !failure() && !cancelled() && needs.build-info.result == 
'success'
     needs:
       - additional-ci-image-checks
       - additional-prod-image-tests
@@ -1255,12 +1352,15 @@ jobs:
     name: "Notify Slack"
     needs:
       - build-info
+      - check-run-needed
       - finalize-tests
       - tests-agent-frameworks
+    # A no-op canary (`run-needed` is 'false') does not post; a failed check 
does.
     if: >-
       always() &&
       !cancelled() &&
       github.event_name == 'schedule' &&
+      needs.check-run-needed.outputs.run-needed != 'false' &&
       github.run_attempt == 1
     runs-on: ["ubuntu-slim"]
     steps:
diff --git a/.github/workflows/ci-arm.yml b/.github/workflows/ci-arm.yml
index 4b3c716437c..fb76a3bed5d 100644
--- a/.github/workflows/ci-arm.yml
+++ b/.github/workflows/ci-arm.yml
@@ -16,9 +16,9 @@
 # under the License.
 #
 ---
-# ARM canary CI workflow. Triggered on the canary cron only — ARM runners are
-# reserved for the scheduled slot. Companion to `ci-amd.yml`, which runs the
-# same pipeline on AMD for every PR and push. The two workflows are physical
+# ARM canary CI workflow. Triggered on the canary cron and on release branches 
by
+# `release-branch-ci.yml` — never on PRs. Companion to `ci-amd.yml`, which 
runs the
+# same pipeline on AMD for every PR. The two workflows are physical
 # copies of each other (GitHub Actions has no cross-file YAML include),
 # differing only in their trigger surface, the hardcoded `runner-type` /
 # `platform` values in `build-info`'s outputs, and the architecture-specific
@@ -31,17 +31,6 @@ on:  # yamllint disable-line rule:truthy
     # architecture). The `:28` vs AMD's `:58` offset keeps the two from 
competing for
     # runners at exactly the same minute.
     - cron: '28 7,19 * * *'
-  push:
-    # Post-merge pushes to release-prep / providers branches run on both
-    # AMD and ARM (the matching block lives in the other wrapper too —
-    # `check-ci-workflows-in-sync` enforces they stay identical). `main` is
-    # intentionally NOT in this list: main pushes stay AMD-only via
-    # `ci-amd.yml`; ARM main coverage is the cron-driven canary above.
-    branches:
-      - v[0-9]+-[0-9]+-test
-      - providers-[a-z]+-?[a-z]*/v[0-9]+-[0-9]+
-      - chart/v[0-9]+-[0-9]+x-test
-      - airflow-ctl/v[0-9]+-[0-9]+-test
   workflow_dispatch:
 permissions:
   # All other permissions are set to none by default
@@ -55,12 +44,97 @@ env:
   VERBOSE: "true"
 
 concurrency:
-  group: ci-arm-${{ github.event.pull_request.number || github.ref }}
-  cancel-in-progress: true
+  # Push runs have a group of their own per branch: a newer push cancels the 
older push run (the
+  # newer run then also tests the cancelled run's changes, see 
`check-run-needed`), and a push never
+  # cancels a full run of the branch.
+  group: >-
+    ci-arm-${{ github.event.pull_request.number || github.ref }}${{
+    github.event_name == 'push' && '-push' || '' }}
+  # A scheduled canary queues behind a run in progress instead of cancelling 
it: it may well be a
+  # manual run of the same commit, which the canary would otherwise cancel and 
then skip itself.
+  cancel-in-progress: ${{ github.event_name != 'schedule' }}
 
 jobs:
+  check-run-needed:
+    name: "Check if the run is needed"
+    # A scheduled canary is not needed when the last completed full run of 
this workflow on the
+    # branch tested the same commit and succeeded: it would only repeat that 
run. A push run tests
+    # the changes since the last successful run of this workflow on the 
branch, found here.
+    # This job runs for every event, with no steps for the others: every other 
job depends on it
+    # through `build-info`, and GitHub skips the whole dependency chain below 
a skipped job unless
+    # each job in it has a status function such as `always()` in its `if:`.
+    runs-on: ["ubuntu-slim"]
+    timeout-minutes: 5
+    permissions:
+      actions: read
+      contents: read
+    outputs:
+      run-needed: ${{ steps.schedule.outputs.run-needed || 'true' }}
+      push-base-commit: ${{ steps.push.outputs.push-base-commit }}
+    steps:
+      - name: "Find the last successful run of the branch"
+        if: github.event_name == 'push'
+        id: push
+        shell: bash
+        env:
+          GH_TOKEN: ${{ github.token }}
+          REPOSITORY: ${{ github.repository }}
+          WORKFLOW_REF: ${{ github.workflow_ref }}
+          BRANCH: ${{ github.ref_name }}
+        run: |
+          set -euo pipefail
+          workflow_path="${WORKFLOW_REF%@*}"
+          # A successful push or full run tested everything up to its commit. 
Changes after it -- from
+          # pushes whose runs failed, or were cancelled by a newer push -- are 
all tested by this run.
+          # A successful run of this same commit is passed over, so that a 
re-run tests again.
+          if ! base="$(gh api 
"repos/${REPOSITORY}/actions/workflows/${workflow_path##*/}/runs" -X GET \
+            -f branch="${BRANCH}" -f status=success -F per_page=30 \
+            --jq "[.workflow_runs[] | select((.event == \"push\" or .event == 
\"workflow_dispatch\")
+                  and .head_sha != \"${GITHUB_SHA}\")][0].head_sha // \"\"")"; 
then
+            # Fail open: a failed lookup must not skip the run.
+            echo "::warning::Could not find the last successful run of 
${BRANCH}, all tests will run"
+            base=""
+          fi
+          # With no successful run to build on, selective checks run all tests.
+          echo "push-base-commit=${base}" >> "${GITHUB_OUTPUT}"
+          echo "Testing the changes since ${base:-the start: no successful run 
yet, so all tests run}." \
+            >> "${GITHUB_STEP_SUMMARY}"
+      - name: "Check the last full run of ${{ github.sha }}"
+        if: github.event_name == 'schedule'
+        id: schedule
+        shell: bash
+        env:
+          GH_TOKEN: ${{ github.token }}
+          REPOSITORY: ${{ github.repository }}
+          WORKFLOW_REF: ${{ github.workflow_ref }}
+          BRANCH: ${{ github.ref_name }}
+        run: |
+          set -euo pipefail
+          workflow_path="${WORKFLOW_REF%@*}"
+          workflow="${workflow_path##*/}"
+          # Full runs are the scheduled canaries and manual 
`workflow_dispatch` runs; this run is
+          # still in progress, so `status=completed` leaves it out.
+          if ! last="$(gh api \
+            
"repos/${REPOSITORY}/actions/workflows/${workflow}/runs?branch=${BRANCH}&status=completed"
 \
+            -F per_page=20 -X GET \
+            --jq '[.workflow_runs[] | select(.event == "schedule" or .event == 
"workflow_dispatch")][0]
+                  | "\(.head_sha // "") \(.conclusion // "") \(.html_url // 
"")"')"; then
+            # Fail open: a canary that cannot check the last run runs, and 
reports to Slack.
+            echo "::warning::Could not find the last full run of ${BRANCH}, 
running the canary"
+            last=""
+          fi
+          read -r last_sha last_conclusion last_url <<< "${last}"
+          if [[ "${last_sha}" == "${GITHUB_SHA}" && "${last_conclusion}" == 
"success" ]]; then
+            echo "run-needed=false" >> "${GITHUB_OUTPUT}"
+            echo "No new commits since the last successful full run: 
${last_url}" >> "${GITHUB_STEP_SUMMARY}"
+          else
+            echo "run-needed=true" >> "${GITHUB_OUTPUT}"
+          fi
+
   build-info:
     name: "Build info"
+    needs: [check-run-needed]
+    if: needs.check-run-needed.outputs.run-needed == 'true'
     # At build-info stage we do not yet have outputs so we need to hard-code 
the runs-on to public runners
     runs-on: ["ubuntu-22.04"]
     outputs:
@@ -179,6 +253,16 @@ jobs:
           ref: ${{ github.sha }}
           fetch-depth: 2
           persist-credentials: false
+      - name: "Fetch the last tested push commit"
+        # Selective checks of a push diff against this commit. A two-commit 
diff only needs the
+        # commit itself, not the history in between; if it cannot be fetched, 
all tests run.
+        if: needs.check-run-needed.outputs.push-base-commit != ''
+        shell: bash
+        env:
+          PUSH_BASE_COMMIT: ${{ 
needs.check-run-needed.outputs.push-base-commit }}
+        run: |
+          git fetch --no-tags --depth=1 origin "${PUSH_BASE_COMMIT}" \
+            || echo "::warning::Could not fetch ${PUSH_BASE_COMMIT}, all tests 
will run"
       - name: "Install Breeze"
         uses: ./.github/actions/breeze
         id: breeze
@@ -220,6 +304,8 @@ jobs:
           VERBOSE: "false"
           GITHUB_CONTEXT_INPUT: "${{ runner.temp }}/github_context.json"
           PLATFORM: "linux/arm64"
+          SELECTIVE_TESTS_ON_PUSH: "true"
+          PUSH_BASE_COMMIT: ${{ 
needs.check-run-needed.outputs.push-base-commit }}
         run: breeze ci selective-check 2>> ${GITHUB_OUTPUT}
       - name: env
         run: printenv
@@ -236,7 +322,7 @@ jobs:
 
   basic-tests:
     name: "Basic tests"
-    needs: [build-info]
+    needs: [build-info, check-run-needed]
     uses: ./.github/workflows/basic-tests.yml
     with:
       runners: ${{ needs.build-info.outputs.runner-type }}
@@ -253,6 +339,7 @@ jobs:
       use-uv: ${{needs.build-info.outputs.use-uv}}
       platform: ${{ needs.build-info.outputs.platform }}
       shared-distributions-as-json: 
${{needs.build-info.outputs.shared-distributions-as-json}}
+      push-base-commit: ${{ needs.check-run-needed.outputs.push-base-commit }}
 
   build-ci-images:
     name: Build CI images
@@ -1191,7 +1278,7 @@ jobs:
       contents: write
       packages: write
     # This will fire when all the jobs from "needs" are either successful or 
skipped
-    if: always() && !failure() && !cancelled()
+    if: always() && !failure() && !cancelled() && needs.build-info.result == 
'success'
     needs:
       - additional-ci-image-checks
       - additional-prod-image-tests
@@ -1244,12 +1331,15 @@ jobs:
     name: "Notify Slack"
     needs:
       - build-info
+      - check-run-needed
       - finalize-tests
       - tests-agent-frameworks
+    # A no-op canary (`run-needed` is 'false') does not post; a failed check 
does.
     if: >-
       always() &&
       !cancelled() &&
       github.event_name == 'schedule' &&
+      needs.check-run-needed.outputs.run-needed != 'false' &&
       github.run_attempt == 1
     runs-on: ["ubuntu-slim"]
     steps:
diff --git a/.github/workflows/ci-image-checks.yml 
b/.github/workflows/ci-image-checks.yml
index 350c21b53b6..affe6ebdb8d 100644
--- a/.github/workflows/ci-image-checks.yml
+++ b/.github/workflows/ci-image-checks.yml
@@ -371,7 +371,8 @@ jobs:
           sudo chown -R "${USER}" .
         if: >
           inputs.canary-run == 'true' &&
-          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch')
+          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch') &&
+          github.ref_name == 'main'
       - name: "Determine Airflow version for SBOMs"
         run: |
           AIRFLOW_VERSION=$(awk -F'"' '/^__version__ = / {print $2}' 
airflow-core/src/airflow/__init__.py)
@@ -379,7 +380,8 @@ jobs:
           echo "AIRFLOW_VERSION=${AIRFLOW_VERSION}" >> "${GITHUB_ENV}"
         if: >
           inputs.canary-run == 'true' &&
-          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch')
+          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch') &&
+          github.ref_name == 'main'
       - name: "Prepare SBOMs"
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -390,14 +392,16 @@ jobs:
           --airflow-constraints-reference constraints-main
         if: >
           inputs.canary-run == 'true' &&
-          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch')
+          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch') &&
+          github.ref_name == 'main'
       - name: "Generated SBOM files"
         run: |
           echo "Generated SBOM files:"
           find ./generated/_build/docs/apache-airflow/stable/sbom/ -type f | 
sort
         if: >
           inputs.canary-run == 'true' &&
-          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch')
+          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch') &&
+          github.ref_name == 'main'
       - name: Check disk space available
         run: df -H
       - name: Create /mnt/airflow-site directory
@@ -438,7 +442,8 @@ jobs:
           rm -rf /tmp/aws/
         if: >
           inputs.canary-run == 'true' &&
-          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch')
+          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch') &&
+          github.ref_name == 'main'
       - name: Configure AWS credentials
         uses: 
aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd  
# v6.3.0
         with:
@@ -447,12 +452,16 @@ jobs:
           aws-region: eu-central-1
         if: >
           inputs.canary-run == 'true' &&
-          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch')
+          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch') &&
+          github.ref_name == 'main'
       - name: "Upload documentation to AWS S3"
+        # `main` only: release branches build a subset of the docs, and 
`--delete` would remove the
+        # rest from the bucket. Their canaries are dispatched by 
`release-branch-ci.yml`.
         run: aws s3 sync --delete ./generated/_build s3://apache-airflow-docs
         if: >
           inputs.canary-run == 'true' &&
-          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch')
+          (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch') &&
+          github.ref_name == 'main'
 
   test-python-api-client:
     timeout-minutes: 60
diff --git a/.github/workflows/refresh-image-registry-cache.yml 
b/.github/workflows/refresh-image-registry-cache.yml
index 23b3e6221dc..e133222cf2d 100644
--- a/.github/workflows/refresh-image-registry-cache.yml
+++ b/.github/workflows/refresh-image-registry-cache.yml
@@ -20,7 +20,7 @@ name: Refresh image registry cache
 # Refreshes the CI image cache in the GitHub registry, in a run of its own.
 #
 # It has to be its own workflow rather than a job inside `ci-amd.yml` / 
`ci-arm.yml`, because a
-# job cannot opt out of its run being cancelled, and those runs are cancelled 
by the next push.
+# job cannot opt out of its run being cancelled, and those runs are cancelled 
by the next run.
 # With no cache a branch builds cold (~20 minutes per Python version), which 
widens the window in
 # which the next push cancels the run, which leaves the cache missing. 
`v3-3-test` sat in that
 # loop -- its `linux/amd64` cache covered the default Python version alone, 
the single matrix
@@ -41,10 +41,11 @@ on:  # yamllint disable-line rule:truthy
           - "linux/arm64"
         default: "both"
   push:
-    # Release-prep and providers branches only -- they are built on push, so 
the CI run that
-    # would have refreshed their cache is also the run that keeps being 
cancelled. `main` is not
-    # built on push; its cache is refreshed by the Push Image Cache job at the 
end of the
-    # scheduled canary, which runs to completion.
+    # Release-prep and providers branches only -- their CI runs are no longer 
started on push but
+    # by `release-branch-ci.yml` a few times a day, and a newer run can still 
cancel the one in
+    # flight, so the cache is refreshed here on every merge instead. `main` is 
not built on push;
+    # its cache is refreshed by the Push Image Cache job at the end of the 
scheduled canary,
+    # which runs to completion.
     branches:
       - v[0-9]+-[0-9]+-test
       - providers-[a-z]+-?[a-z]*/v[0-9]+-[0-9]+
diff --git a/.github/workflows/release-branch-ci.yml 
b/.github/workflows/release-branch-ci.yml
new file mode 100644
index 00000000000..c487197464d
--- /dev/null
+++ b/.github/workflows/release-branch-ci.yml
@@ -0,0 +1,161 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+---
+# Starts the full CI (`ci-amd.yml` / `ci-arm.yml`) on release-prep and 
providers branches on a
+# schedule, instead of on every push to them. During release preparation 
backports land on
+# `vX-Y-test` in bursts, and every push used to start a full AMD + ARM canary 
that the next push
+# cancelled after it had already used hundreds of runner-minutes. Each 
backport PR has already run
+# CI against the branch, so the branch needs a full run of its current HEAD a 
few times a day, not
+# one per merged commit.
+#
+# Each slot dispatches one architecture, for every release branch unless the 
last full run of that
+# workflow on the branch tested its current HEAD and succeeded (or is still in 
progress): a run is
+# repeated only when there are new commits, or when the previous run of the 
same commit failed or
+# was cancelled. Branches without a commit in the last `MAX_IDLE_DAYS` days 
are skipped, so the
+# long tail of old release branches is never built. Release managers can start 
a run at any time
+# with `workflow_dispatch` here, or directly on `ci-amd.yml` / `ci-arm.yml` 
with
+# `gh workflow run ci-amd.yml --repo apache/airflow --ref vX-Y-test`.
+#
+# `schedule` runs only from the default branch, which is why this lives on 
`main` and dispatches
+# the workflows on the release branch: the dispatched run uses the workflow 
files of that branch.
+name: Release branch CI
+on:  # yamllint disable-line rule:truthy
+  schedule:
+    # AMD at 04:43 and 16:43, ARM at 10:43 and 22:43 UTC: between the `main` 
canaries
+    # (AMD 01:58/13:58, ARM 07:28/19:28), so the two never compete for runners 
at the same time.
+    # Keep these strings in sync with AMD_CRON below.
+    - cron: '43 4,16 * * *'
+    - cron: '43 10,22 * * *'
+  workflow_dispatch:
+    inputs:
+      platform:
+        description: "Which CI workflow to start"
+        type: choice
+        options:
+          - "both"
+          - "amd"
+          - "arm"
+        default: "both"
+      branch:
+        description: "Release branch to build (empty: every release branch 
with new commits)"
+        type: string
+        default: ""
+      force:
+        description: "Start the run even if the last run of the branch HEAD 
succeeded"
+        type: boolean
+        default: false
+permissions:
+  contents: read
+concurrency:
+  group: release-branch-ci
+  cancel-in-progress: false
+jobs:
+  dispatch:
+    name: "Dispatch release branch CI"
+    if: github.repository == 'apache/airflow'
+    runs-on: ["ubuntu-slim"]
+    timeout-minutes: 10
+    permissions:
+      actions: write
+      contents: read
+    steps:
+      - name: "Start CI for release branches with new commits"
+        env:
+          GH_TOKEN: ${{ github.token }}
+          REPOSITORY: ${{ github.repository }}
+          EVENT_NAME: ${{ github.event_name }}
+          SCHEDULE: ${{ github.event.schedule }}
+          AMD_CRON: '43 4,16 * * *'
+          PLATFORM: ${{ inputs.platform }}
+          BRANCH: ${{ inputs.branch }}
+          FORCE: ${{ inputs.force }}
+          MAX_IDLE_DAYS: "7"
+          # Same branches as the `pull_request` filter of `ci-amd.yml`, minus 
the `-stable` ones.
+          RELEASE_BRANCH_REGEX: >-
+            
^(v[0-9]+-[0-9]+-test|providers-[a-z]+-?[a-z]*/v[0-9]+-[0-9]+|chart/v[0-9]+-[0-9]+x-test|airflow-ctl/v[0-9]+-[0-9]+-test)$
+        run: |
+          set -euo pipefail
+          if [[ "${EVENT_NAME}" == "schedule" ]]; then
+            if [[ "${SCHEDULE}" == "${AMD_CRON}" ]]; then
+              workflows=("ci-amd.yml")
+            else
+              workflows=("ci-arm.yml")
+            fi
+          else
+            case "${PLATFORM}" in
+              amd) workflows=("ci-amd.yml") ;;
+              arm) workflows=("ci-arm.yml") ;;
+              *) workflows=("ci-amd.yml" "ci-arm.yml") ;;
+            esac
+          fi
+          if [[ -n "${BRANCH}" && ! "${BRANCH}" =~ ${RELEASE_BRANCH_REGEX} ]]; 
then
+            echo "::error::'${BRANCH}' is not a release branch (expected to 
match ${RELEASE_BRANCH_REGEX})"
+            exit 1
+          fi
+          cutoff="$(date -u -d "-${MAX_IDLE_DAYS} days" +%s)"
+          {
+            echo "| Branch | Workflow | HEAD | Action |"
+            echo "| --- | --- | --- | --- |"
+          } >> "${GITHUB_STEP_SUMMARY}"
+          gh api --paginate "repos/${REPOSITORY}/branches?per_page=100" \
+            --jq '.[] | [.name, .commit.sha] | @tsv' > 
"${RUNNER_TEMP}/branches.tsv"
+          while IFS=$'\t' read -r branch head_sha; do
+            [[ "${branch}" =~ ${RELEASE_BRANCH_REGEX} ]] || continue
+            [[ -z "${BRANCH}" || "${branch}" == "${BRANCH}" ]] || continue
+            if [[ -z "${BRANCH}" ]]; then
+              committed="$(gh api "repos/${REPOSITORY}/commits/${head_sha}" 
--jq '.commit.committer.date')"
+              if (( $(date -u -d "${committed}" +%s) < cutoff )); then
+                continue
+              fi
+            fi
+            # A branch without this change backported still builds in full on 
push, and a run
+            # dispatched there would use its old `ci-image-checks.yml`, which 
publishes a canary's
+            # partial docs build to the docs bucket of `main`.
+            
marker="repos/${REPOSITORY}/contents/.github/workflows/release-branch-ci.yml"
+            if ! gh api --silent "${marker}?ref=${branch//\//%2F}"; then
+              echo "${branch}: skipped, release-branch-ci.yml not backported"
+              echo "| \`${branch}\` | - | \`${head_sha:0:10}\` | skipped: not 
backported |" \
+                >> "${GITHUB_STEP_SUMMARY}"
+              continue
+            fi
+            for workflow in "${workflows[@]}"; do
+              # The newest full run of this workflow on the branch. Push runs 
only run the selective
+              # tests of their commit, so they do not count.
+              last="$(gh api \
+                
"repos/${REPOSITORY}/actions/workflows/${workflow}/runs?branch=${branch//\//%2F}"
 \
+                -X GET -f event=workflow_dispatch -F per_page=1 \
+                --jq '.workflow_runs[0] | "\(.head_sha // "") \(.status // "") 
\(.conclusion // "")"')"
+              read -r last_sha last_status last_conclusion <<< "${last}"
+              if [[ "${FORCE}" != "true" && "${last_sha}" == "${head_sha}" \
+                    && ( "${last_status}" != "completed" || 
"${last_conclusion}" == "success" ) ]]; then
+                action="skipped: last run of HEAD 
${last_conclusion:-${last_status}}"
+              else
+                # A branch cut before `ci-amd.yml` / `ci-arm.yml` existed 
cannot run them; report it and
+                # carry on with the other branches.
+                if gh workflow run "${workflow}" --repo "${REPOSITORY}" --ref 
"${branch}"; then
+                  action="started"
+                else
+                  action="could not start"
+                  echo "::warning::Could not start ${workflow} on ${branch}"
+                fi
+              fi
+              echo "${branch} ${workflow} ${head_sha:0:10}: ${action}"
+              echo "| \`${branch}\` | \`${workflow}\` | \`${head_sha:0:10}\` | 
${action} |" \
+                >> "${GITHUB_STEP_SUMMARY}"
+            done
+          done < "${RUNNER_TEMP}/branches.tsv"
diff --git a/dev/README_AIRFLOW3_DEV.md b/dev/README_AIRFLOW3_DEV.md
index d99a81a60f7..9249f063551 100644
--- a/dev/README_AIRFLOW3_DEV.md
+++ b/dev/README_AIRFLOW3_DEV.md
@@ -78,10 +78,11 @@ PRs should **never** target `v3-2-stable` directly unless 
explicitly instructed
 > RC testing. In this case, the release manager can prepare the RC directly 
 > from the `v3-X-test` branch
 > without opening a PR to `v3-X-stable`. This saves the overhead of creating 
 > and managing a PR that will
 > likely need additional changes before GA. However, when using this shortcut, 
 > the release manager **must**
-> verify that the `v3-X-test` push CI action ("Tests" workflow) has succeeded 
before cutting the RC. You can
-> check this at:
-> 
https://github.com/apache/airflow/actions/workflows/ci-amd.yml?query=event%3Apush+branch%3Av3-2-test
-> (adjust the branch filter for the relevant `v3-X-test` branch).
+> verify that the full CI runs ("Tests (AMD)" and "Tests (ARM)" workflows) of 
the `v3-X-test` HEAD have
+> succeeded before cutting the RC. They are started by 
`release-branch-ci.yml`, not on push; see
+> [CI of the `vX-Y-test` 
branch](README_RELEASE_AIRFLOW.md#ci-of-the-vx-y-test-branch). You can see them 
at:
+> 
https://github.com/apache/airflow/actions/workflows/ci-amd.yml?query=event%3Aworkflow_dispatch+branch%3Av3-2-test
+> (adjust the branch filter for the relevant `v3-X-test` branch, and check 
`ci-arm.yml` the same way).
 
 ## Developing for Airflow 3
 
diff --git a/dev/README_RELEASE_AIRFLOW.md b/dev/README_RELEASE_AIRFLOW.md
index c7dd2f54855..5c81a5f8461 100644
--- a/dev/README_RELEASE_AIRFLOW.md
+++ b/dev/README_RELEASE_AIRFLOW.md
@@ -23,6 +23,7 @@
 - [Collect ambiguities during the release (for a follow-up doc 
PR)](#collect-ambiguities-during-the-release-for-a-follow-up-doc-pr)
 - [Perform review of security issues that are marked for the 
release](#perform-review-of-security-issues-that-are-marked-for-the-release)
 - [Selecting what to put into the 
release](#selecting-what-to-put-into-the-release)
+  - [CI of the `vX-Y-test` branch](#ci-of-the-vx-y-test-branch)
   - [Beta releases: fast-forwarding `vX-Y-test` to 
`main`](#beta-releases-fast-forwarding-vx-y-test-to-main)
   - [i18n workflow](#i18n-workflow)
   - [Selecting what to cherry-pick](#selecting-what-to-cherry-pick)
@@ -105,6 +106,54 @@ The first step of a release is to work out what is being 
included. This differs
 
 - For a *patch* release, you will be selecting specific commits to cherry-pick 
and backport into the existing release branch.
 
+## CI of the `vX-Y-test` branch
+
+Release branches do not run the full CI on every push. Backports land in 
bursts while a release is
+prepared, and a full AMD + ARM run per merged commit was mostly cancelled by 
the next merge. Instead:
+
+- **Full runs** are started by
+  [`release-branch-ci.yml`](../.github/workflows/release-branch-ci.yml) on 
every release branch whose
+  HEAD has not had a successful full run yet: `Tests (AMD)` at 04:43 and 16:43 
UTC, `Tests (ARM)` at
+  10:43 and 22:43 UTC. A failed or cancelled run of HEAD is repeated at the 
next slot. They are
+  `workflow_dispatch` runs of `ci-amd.yml` / `ci-arm.yml` on the branch, run 
the full matrix, and
+  refresh the branch's constraints and image cache, as the push runs used to.
+- **Incremental tests on push**: every push starts a `Tests (AMD)` run that 
tests only what changed
+  since the last successful run of `Tests (AMD)` on the branch (push or full 
run), selected the same
+  way as for a PR with those changes. A newer push cancels the push run still 
in progress, and its
+  run then also covers the cancelled run's changes; changes from a failed run 
are tested again by
+  the next one. A broken backport therefore shows up within the hour, not at 
the next full run.
+
+Nothing needs to be switched on or off for a release. Branches cut from `main` 
after this was
+introduced have it already. An older release branch only behaves this way once 
the change to
+`ci-amd.yml` / `ci-arm.yml` that introduced it has been backported to it; 
until then it keeps
+running the full CI on every push.
+
+### Before cutting an RC: check the full run of the branch HEAD
+
+The incremental push runs do not replace the full run. Before you cut an RC 
(or open the PR syncing
+`vX-Y-test` into `vX-Y-stable`), make sure both the AMD and the ARM full run 
of the current HEAD of
+`vX-Y-test` have succeeded:
+
+```shell script
+git ls-remote https://github.com/apache/airflow.git refs/heads/vX-Y-test
+gh run list --repo apache/airflow --workflow ci-amd.yml --branch vX-Y-test 
--event workflow_dispatch \
+    --limit 1 --json headSha,status,conclusion,url
+gh run list --repo apache/airflow --workflow ci-arm.yml --branch vX-Y-test 
--event workflow_dispatch \
+    --limit 1 --json headSha,status,conclusion,url
+```
+
+The `headSha` of each run must be the SHA `git ls-remote` printed. If the last 
run is for an older
+commit, start the full runs now instead of waiting for the next slot, and 
check again when they finish:
+
+```shell script
+gh workflow run release-branch-ci.yml --repo apache/airflow -f branch=vX-Y-test
+```
+
+This starts both architectures for the branch HEAD (add `-f platform=amd` or 
`-f platform=arm` for
+one of them, and `-f force=true` to repeat a run that already succeeded for 
HEAD). You can also start
+one directly with `gh workflow run ci-amd.yml --repo apache/airflow --ref 
vX-Y-test`. Starting a new
+full run cancels the full run of the same workflow already in progress on the 
branch.
+
 ## Beta releases: fast-forwarding `vX-Y-test` to `main`
 
 For a *major* or *minor* release, the `vX-Y-test` branch is created early (see
@@ -608,10 +657,12 @@ still works but is no longer recommended.
 > RC testing. In this case, the release manager can prepare the RC directly 
 > from the `v3-X-test` branch
 > without opening a PR to `v3-X-stable`. This saves the overhead of creating 
 > and managing a PR that will
 > likely need additional changes before GA. However, when using this shortcut, 
 > the release manager **must**
-> verify that the `v3-X-test` push CI action ("Tests" workflow) has succeeded 
before cutting the RC. You can
-> check this at:
-> 
https://github.com/apache/airflow/actions/workflows/ci-amd.yml?query=event%3Apush+branch%3Av3-2-test
-> (adjust the branch filter for the relevant `v3-X-test` branch).
+> verify that the full CI runs ("Tests (AMD)" and "Tests (ARM)" workflows) of 
the `v3-X-test` HEAD have
+> succeeded before cutting the RC, as described in
+> [Before cutting an RC: check the full run of the branch 
HEAD](#before-cutting-an-rc-check-the-full-run-of-the-branch-head).
+> You can see them at:
+> 
https://github.com/apache/airflow/actions/workflows/ci-amd.yml?query=event%3Aworkflow_dispatch+branch%3Av3-2-test
+> (adjust the branch filter for the relevant `v3-X-test` branch, and check 
`ci-arm.yml` the same way).
 
 - When the PR is approved (or when using the shortcut above), install 
`dev/breeze` in a virtualenv:
 
diff --git a/dev/README_RELEASE_AIRFLOWCTL.md b/dev/README_RELEASE_AIRFLOWCTL.md
index 7e8b8fa2673..e87f018cb89 100644
--- a/dev/README_RELEASE_AIRFLOWCTL.md
+++ b/dev/README_RELEASE_AIRFLOWCTL.md
@@ -233,8 +233,16 @@ create the X.Y branches" above before proceeding.
 > for a new minor (e.g. `0.2.0rc1`), the release manager may cut the RC
 > directly from `airflow-ctl/vX-Y-test` without opening a sync PR to
 > `airflow-ctl/vX-Y-stable`. However, they **must** verify that the
-> `airflow-ctl/vX-Y-test` push CI action ("Tests" workflow) has succeeded
-> before cutting the RC.
+> full CI runs ("Tests (AMD)" and "Tests (ARM)" workflows) of the
+> `airflow-ctl/vX-Y-test` HEAD have succeeded before cutting the RC.
+
+Release branches do not run the full CI on push: `release-branch-ci.yml`
+starts it a few times a day until the branch HEAD has a successful full
+run, and each push runs only the tests for the changes since the last
+successful run. Check the full run of the HEAD before cutting an RC as
+described in
+[CI of the `vX-Y-test` 
branch](README_RELEASE_AIRFLOW.md#ci-of-the-vx-y-test-branch)
+(use `airflow-ctl/vX-Y-test` as the branch name).
 
 Before cutting an RC, confirm the set of commits on the maintenance
 branch is what you expect:
diff --git a/dev/breeze/doc/ci/04_selective_checks.md 
b/dev/breeze/doc/ci/04_selective_checks.md
index 2f788e669f4..7b383f95566 100644
--- a/dev/breeze/doc/ci/04_selective_checks.md
+++ b/dev/breeze/doc/ci/04_selective_checks.md
@@ -144,8 +144,9 @@ flowchart TD
     A([_should_run_all_tests_and_versions]) --> B{event is<br/>PUSH / SCHEDULE 
/ DISPATCH?}
     B -->|yes| C{PUSH and only<br/>.txt/.md files changed?}
     C -->|yes| NO1[False<br/>skip full tests]
-    C -->|no| D{PUSH and<br/>branch != main?}
-    D -->|yes - release branch push| NO2[False<br/>selective only]
+    C -->|no| D{PUSH with<br/>--selective-tests-on-push?}
+    D -->|yes - release branch push in ci-amd.yml| NO2[treated like 
PULL_REQUEST<br/>selective]
+    NO2 --> E
     D -->|no| YES1[True<br/>run everything]
     B -->|no - PULL_REQUEST| E{commit_ref missing?}
     E -->|yes| YES2[True]
@@ -184,9 +185,12 @@ Two related booleans share this machinery:
   versions instead of just the defaults. Labels (`default versions only`, 
`latest versions only`,
   `all versions`) override; otherwise it follows 
`_should_run_all_tests_and_versions()`.
 * **`is_canary_run`** — `True` for SCHEDULE/PUSH/DISPATCH events **on 
`apache/airflow`** (not forks)
-  when more than just `.txt`/`.md` changed, or whenever the `canary` label is 
set. Canary runs always
-  run unit tests even when `full_tests_needed` is `False` (this is what makes 
a *release-branch push*
-  still run its relevant unit tests — see the examples).
+  when more than just `.txt`/`.md` changed, or whenever the `canary` label is 
set. A PUSH with
+  `--selective-tests-on-push` (env `SELECTIVE_TESTS_ON_PUSH`) is **not** a 
canary: `ci-amd.yml` passes
+  that flag, so a push to a release branch runs the selective tests of the 
changes since the last
+  successful run of the branch (`--push-base-commit`), exactly as a pull 
request would. Workflows that run on push for other reasons (constraints, image 
cache) do not
+  pass it, and keep getting the full set of Python versions they need. Canary 
runs always run unit
+  tests even when `full_tests_needed` is `False`.
 
 ### Stage 2 — decide which jobs run
 
@@ -289,13 +293,13 @@ flowchart TD
         PR1[event=PULL_REQUEST<br/>files=providers/amazon/.../s3.py] --> 
PR2[full_tests_needed=False<br/>all_versions=False<br/>is_canary_run=False]
         PR2 --> PR3[unit tests: Providers amazon + Always<br/>default versions 
only<br/>amazon prek/mypy only]
     end
-    subgraph PUSHMAIN[Push / merge to main]
-        PM1[event=PUSH, branch=main] --> 
PM2[full_tests_needed=True<br/>all_versions=True<br/>is_canary_run=True]
-        PM2 --> PM3[full matrix, all versions,<br/>all canary-only jobs]
+    subgraph RELFULL[Release branch full run, dispatched]
+        RF1[event=WORKFLOW_DISPATCH<br/>branch=v3-x-test] --> 
RF2[full_tests_needed=True<br/>all_versions=True<br/>is_canary_run=True]
+        RF2 --> RF3[full matrix, all versions,<br/>all canary-only jobs]
     end
     subgraph RELPUSH[Push to release branch v3-x-test]
-        RP1[event=PUSH, branch!=main] --> 
RP2[full_tests_needed=False<br/>is_canary_run=True]
-        RP2 --> RP3[unit tests run via canary<br/>but selective test 
types<br/>default versions]
+        RP1[event=PUSH<br/>--selective-tests-on-push] --> 
RP2[full_tests_needed=False<br/>is_canary_run=False]
+        RP2 --> RP3[same selection as a PR<br/>with the files changed 
since<br/>the last successful run<br/>default versions]
     end
     subgraph CANARY[Scheduled nightly canary]
         CN1[event=SCHEDULE on apache/airflow] --> 
CN2[full_tests_needed=True<br/>all_versions=True<br/>is_canary_run=True]
@@ -315,13 +319,16 @@ flowchart TD
    PR runs the **full matrix** — same as a canary — because the change can 
affect everything. (A *non-test*
    workflow such as `codeql-analysis.yml`, or a `scripts/ci/prek/...` 
static-check hook, does **not** force
    the full matrix — see the `Environment files` carve-outs above.)
-4. **Push / merge to `main`.** `_should_run_all_tests_and_versions()` is true 
for PUSH on `main` →
-   `full_tests_needed=True`, `all_versions=True`, `is_canary_run=True`. The 
full matrix plus all
-   canary-only jobs run. This is the safety net that backstops aggressive 
PR-time optimisation.
-5. **Push to a release branch** (`v3-1-test`). PUSH but `branch != main` → 
`full_tests_needed=False`
-   (selective), yet `is_canary_run=True`, so `run_unit_tests=True`. The branch 
runs its *relevant*
-   unit tests on default versions rather than the entire matrix — release 
branches don't need the full
-   cross-version sweep on every push.
+4. **Full run of a release branch** (WORKFLOW_DISPATCH on `v3-1-test`, started 
by
+   `release-branch-ci.yml` a few times a day or by a release manager). 
`full_tests_needed=True`,
+   `all_versions=True`, `is_canary_run=True`. The full matrix plus all 
canary-only jobs run, and the
+   branch's constraints and image cache are refreshed. `main` gets the same 
from the scheduled canary;
+   neither builds the full matrix on push.
+5. **Push to a release branch** (`v3-1-test`). `ci-amd.yml` passes 
`--selective-tests-on-push` and
+   `--push-base-commit` (the commit of the last successful run of the workflow 
on the branch), so the
+   push is treated like a pull request whose files are those changed since 
that commit:
+   `full_tests_needed` and `is_canary_run` follow the PR rules, and the run 
selects only the tests
+   those changes need, on default versions. If the diff cannot be computed, 
all tests run.
 6. **Scheduled nightly canary** (SCHEDULE on `apache/airflow`). Full matrix on 
all versions, every
    canary-only job. The most thorough run; it is what gives us confidence that 
the PR-time shortcuts
    never let a regression through.
@@ -433,7 +440,7 @@ together using `pytest-xdist` (pytest-xdist distributes the 
tests among parallel
 
 ## Selective check decision rules
 
-* `Full tests` case is enabled when the event is PUSH **to `main`**, SCHEDULE 
or WORKFLOW_DISPATCH, or we
+* `Full tests` case is enabled when the event is PUSH, SCHEDULE or 
WORKFLOW_DISPATCH, or we
   miss commit info, or any of the important environment files 
(`pyproject.toml`, `Dockerfile`, `scripts`,
   etc.) changed, or the API *contract* changed (the generated OpenAPI spec or 
the client generator —
   plain API source/test edits that leave the committed spec
@@ -443,9 +450,9 @@ together using `pytest-xdist` (pytest-xdist distributes the 
tests among parallel
   set. Any other changed `tests_common` helper only selects the tests that 
import it.
   That enables all matrix combinations of variables (representative) and all 
possible test type. No further
   checks are performed. See also [1] note below. Two exceptions narrow this: a 
PUSH that changed **only**
-  `.txt`/`.md` files skips full tests, and a PUSH to a **release branch** 
(`v3-X-test`, i.e. not `main`)
-  runs selective tests only (it is still a `canary` run, so its *relevant* 
unit tests run on default
-  versions). The high-level flow and worked examples for these cases are in
+  `.txt`/`.md` files skips full tests, and a PUSH with 
`--selective-tests-on-push` (which `ci-amd.yml`
+  passes for pushes to release branches) is treated like a pull request — 
selective tests on default
+  versions, not a `canary` run. The high-level flow and worked examples for 
these cases are in
   [The decision pipeline](#the-decision-pipeline) and
   [Worked examples](#worked-examples-pull-requests-pushes-and-canary-runs) 
above.
 * Python, Kubernetes, Backend, Kind, Helm versions are limited to "defaults" 
only unless `Full tests` mode
diff --git a/dev/breeze/doc/ci/05_workflows.md 
b/dev/breeze/doc/ci/05_workflows.md
index e62dc41a283..c59950dc89f 100644
--- a/dev/breeze/doc/ci/05_workflows.md
+++ b/dev/breeze/doc/ci/05_workflows.md
@@ -94,8 +94,9 @@ label in the PR if you are a committer and want to force 
dependency upgrade.
 
 ## Canary run
 
-This workflow is triggered when a pull request is merged into the `main`
-branch or pushed to any of the `v*-*-test` branches. The `canary` run
+This workflow runs on a schedule on the `main` branch, and a few times a day
+on the release branches that have new commits (see
+[Release branch runs](#release-branch-runs)). The `canary` run
 aims to upgrade dependencies to their latest versions and promptly
 pushes a preview of the CI/PROD image cache to the GitHub Registry. This
 allows pull requests to quickly utilize the new cache, which is
@@ -116,9 +117,18 @@ constraints will not be updated, meaning that regular PRs 
will continue
 using the older version of dependencies that passed one of the previous
 `canary` runs.
 
-The `canary` runs are executed 6 times a day on schedule, you can also
+The `canary` runs are scheduled 4 times a day on `main`, alternating between
+`ci-amd.yml` (01:58, 13:58 UTC) and `ci-arm.yml` (07:28, 19:28 UTC). You can 
also
 trigger the `canary` run manually via `workflow-dispatch` mechanism.
 
+A scheduled canary that has nothing new to test finishes at once: its first 
job,
+`Check if the run is needed`, looks up the last completed full run (`schedule` 
or
+`workflow_dispatch`) of the same workflow on the branch, and when that run 
tested the
+same commit and succeeded, every other job is skipped and the run succeeds. 
When the
+last run failed or was cancelled, or new commits landed since, the canary runs 
in full.
+A skipped canary does not post to Slack. A canary on an unchanged commit 
therefore does
+not pick up dependencies released since the last run; the next commit's canary 
does.
+
 # Workflows
 
 Apache Airflow's CI system has evolved into what we call a composite workflow 
architecture. This approach breaks down our testing process into manageable, 
reusable pieces that work together to validate code changes.
@@ -175,13 +185,61 @@ Release branches (named `v*-*-test`) get a more focused 
set of tests. These bran
 
 This approach saves time and resources while still ensuring release quality.
 
+### Release branch runs
+
+Release-prep and providers branches (`vX-Y-test`, `providers-<name>/vX-Y`, 
`chart/vX-Yx-test`,
+`airflow-ctl/vX-Y-test`) do **not** run the full CI on push. Backports land in 
bursts while a release
+is prepared, and a full AMD + ARM run per merged commit was mostly cancelled 
by the next merge after
+it had already used hundreds of runner-minutes. Every backport PR has run CI 
against the branch
+already, so the branch needs a full run of its current HEAD a few times a day, 
not one per commit.
+
+**Full runs.** 
[`release-branch-ci.yml`](../../../../.github/workflows/release-branch-ci.yml) 
runs from
+`main` on a schedule and starts `ci-amd.yml` or `ci-arm.yml` with 
`workflow_dispatch` on each release
+branch, unless the last `workflow_dispatch` run of that workflow on the branch 
tested its current HEAD
+and succeeded, or is still running. A run of an unchanged HEAD is repeated 
only when the previous one
+failed or was cancelled:
+
+| Slot (UTC)   | Workflow started |
+|--------------|------------------|
+| 04:43, 16:43 | `ci-amd.yml`     |
+| 10:43, 22:43 | `ci-arm.yml`     |
+
+A `workflow_dispatch` run on a release branch is a canary run: it runs the 
full matrix and refreshes
+the branch's constraints and image cache. Branches without a commit in the 
last 7 days are skipped.
+`release-branch-ci.yml` can also be started by hand, with optional `branch`, 
`platform` and `force`
+inputs.
+
+**Incremental tests on push.** `ci-amd.yml` still has a `push` trigger for 
these branches (AMD only),
+but a push run only tests what changed since the last successful run of 
`ci-amd.yml` on the branch,
+whether that was a push run or a full run. The first job, `Check if the run is 
needed`, finds that
+run's commit through the Actions API on `ubuntu-slim`. `build-info` fetches 
that one commit (a
+two-commit diff needs no history in between) and passes it to selective checks 
as
+`--push-base-commit`, together with `--selective-tests-on-push`, so the run 
selects tests from the
+files changed since that commit as for a pull request, and is not a canary 
run. If there is no
+successful run to build on, or the base commit cannot be fetched or diffed, 
all tests run. A
+successful run of the same commit is passed over, so re-running a push run 
tests it again.
+
+Push runs have a concurrency group of their own per branch: a newer push 
cancels the push run still
+in progress, and the newer run then covers the cancelled run's changes too, 
because a cancelled run
+is not a successful one. A failed run's changes are tested again the same way. 
A push never cancels a
+full run of the branch.
+
+Nothing has to be switched on or off for a release.
+
+The workflow files of the release branch itself decide what a run does, so an 
older release branch
+only gets this behaviour once the change that introduced it has been 
backported to it. Until then it
+keeps running the full CI on every push.
+
+The CI image cache of `vX-Y-test` and providers branches is still refreshed on 
every push, by
+[`refresh-image-registry-cache.yml`](../../../../.github/workflows/refresh-image-registry-cache.yml).
+
 ### Branch Detection Logic
 
 The system determines which tests to run through the `build-info` job. This 
job checks the `AIRFLOW_BRANCH` setting in `airflow_breeze/branch_defaults.py` 
to understand what branch type it's working with, then enables or disables test 
groups accordingly.
 
 ## Tests Workflow Structure
 
-The Tests workflow handles all code validation for Apache Airflow. When 
changes get pushed to `main` or release branches, these runs become "canary" 
builds that also update shared infrastructure like dependency constraints and 
Docker images.
+The Tests workflow handles all code validation for Apache Airflow. Its 
scheduled runs on `main` and its full runs on release branches (see [Release 
branch runs](#release-branch-runs)) are "canary" builds that also update shared 
infrastructure like dependency constraints and Docker images.
 
 ### Workflow Execution Flow
 
diff --git a/dev/breeze/doc/images/output_ci_selective-check.svg 
b/dev/breeze/doc/images/output_ci_selective-check.svg
index 6c5e3064794..86043541c57 100644
--- a/dev/breeze/doc/images/output_ci_selective-check.svg
+++ b/dev/breeze/doc/images/output_ci_selective-check.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 1482 757.5999999999999" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 1482 855.1999999999999" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -43,7 +43,7 @@
 
     <defs>
     <clipPath id="breeze-ci-selective-check-clip-terminal">
-      <rect x="0" y="0" width="1463.0" height="706.5999999999999" />
+      <rect x="0" y="0" width="1463.0" height="804.1999999999999" />
     </clipPath>
     <clipPath id="breeze-ci-selective-check-line-0">
     <rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -129,9 +129,21 @@
 <clipPath id="breeze-ci-selective-check-line-27">
     <rect x="0" y="660.3" width="1464" height="24.65"/>
             </clipPath>
+<clipPath id="breeze-ci-selective-check-line-28">
+    <rect x="0" y="684.7" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-selective-check-line-29">
+    <rect x="0" y="709.1" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-selective-check-line-30">
+    <rect x="0" y="733.5" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-selective-check-line-31">
+    <rect x="0" y="757.9" width="1464" height="24.65"/>
+            </clipPath>
     </defs>
 
-    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="755.6" rx="8"/><text 
class="breeze-ci-selective-check-title" fill="#c5c8c6" text-anchor="middle" 
x="740" y="27">Command:&#160;ci&#160;selective-check</text>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="853.2" rx="8"/><text 
class="breeze-ci-selective-check-title" fill="#c5c8c6" text-anchor="middle" 
x="740" y="27">Command:&#160;ci&#160;selective-check</text>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
@@ -154,22 +166,26 @@
 </text><text class="breeze-ci-selective-check-r5" x="0" y="264" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-10)">│</text><text 
class="breeze-ci-selective-check-r6" x="390.4" y="264" textLength="73.2" 
clip-path="url(#breeze-ci-selective-check-line-10)">(TEXT)</text><text 
class="breeze-ci-selective-check-r5" x="1451.8" y="264" textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-10)">│</text><text 
class="breeze-ci-selective-check-r1" x="1464" y="264" textLe [...]
 </text><text class="breeze-ci-selective-check-r5" x="0" y="288.4" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-11)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="288.4" textLength="341.6" 
clip-path="url(#breeze-ci-selective-check-line-11)">--platform&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-selective-check-r1" x="390.4" y="288.4" textLength="1049.2" 
clip-pa [...]
 </text><text class="breeze-ci-selective-check-r5" x="0" y="312.8" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-12)">│</text><text 
class="breeze-ci-selective-check-r1" x="390.4" y="312.8" textLength="170.8" 
clip-path="url(#breeze-ci-selective-check-line-12)">are&#160;testable.&#160;</text><text
 class="breeze-ci-selective-check-r5" x="561.2" y="312.8" textLength="268.4" 
clip-path="url(#breeze-ci-selective-check-line-12)">[default:&#160;linux/amd64]</text><text
 class="br [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="337.2" 
textLength="1464" 
clip-path="url(#breeze-ci-selective-check-line-13)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-selective-check-r1" x="1464" y="337.2" textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-13)">
-</text><text class="breeze-ci-selective-check-r5" x="0" y="361.6" 
textLength="24.4" 
clip-path="url(#breeze-ci-selective-check-line-14)">╭─</text><text 
class="breeze-ci-selective-check-r5" x="24.4" y="361.6" textLength="231.8" 
clip-path="url(#breeze-ci-selective-check-line-14)">&#160;GitHub&#160;parameters&#160;</text><text
 class="breeze-ci-selective-check-r5" x="256.2" y="361.6" textLength="1183.4" 
clip-path="url(#breeze-ci-selective-check-line-14)">──────────────────────────────────────
 [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="386" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-15)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="386" textLength="268.4" 
clip-path="url(#breeze-ci-selective-check-line-15)">--github-event-name&#160;&#160;&#160;</text><text
 class="breeze-ci-selective-check-r1" x="366" y="386" textLength="610" 
clip-path="url(#breeze-ci-selective-check-line-15)">Name&#160;of&#160;the&#160;GitHub&#160;event&#1
 [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="410.4" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-16)">│</text><text 
class="breeze-ci-selective-check-r6" x="366" y="410.4" textLength="1073.6" 
clip-path="url(#breeze-ci-selective-check-line-16)">|&#160;pull_request_review&#160;|&#160;pull_request_target&#160;|&#160;pull_request_workflow&#160;|&#160;push&#160;|&#160;schedule&#160;|&#160;</text><text
 class="breeze-ci-selective-check-r5" x="1451.8" y="410.4" t [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="434.8" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-17)">│</text><text 
class="breeze-ci-selective-check-r6" x="366" y="434.8" textLength="402.6" 
clip-path="url(#breeze-ci-selective-check-line-17)">workflow_dispatch&#160;|&#160;workflow_run)</text><text
 class="breeze-ci-selective-check-r5" x="1451.8" y="434.8" textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-17)">│</text><text 
class="breeze-ci-s [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="459.2" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-18)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="459.2" textLength="268.4" 
clip-path="url(#breeze-ci-selective-check-line-18)">--github-repository&#160;&#160;&#160;</text><text
 class="breeze-ci-selective-check-r7" x="317.2" y="459.2" textLength="24.4" 
clip-path="url(#breeze-ci-selective-check-line-18)">-g</text><text 
class="breeze-ci-select [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="483.6" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-19)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="483.6" textLength="268.4" 
clip-path="url(#breeze-ci-selective-check-line-19)">--github-actor&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-selective-check-r1" x="366" y="483.6" textLength="549" 
clip-path="url(#breeze-ci-selective-check-line-19)">Actor&#160;that& [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="508" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-20)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="508" textLength="268.4" 
clip-path="url(#breeze-ci-selective-check-line-20)">--github-context&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-selective-check-r1" x="366" y="508" textLength="695.4" 
clip-path="url(#breeze-ci-selective-check-line-20)">GitHub&#160;context&#160;(JSON
 [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="532.4" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-21)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="532.4" textLength="268.4" 
clip-path="url(#breeze-ci-selective-check-line-21)">--github-context-input</text><text
 class="breeze-ci-selective-check-r1" x="366" y="532.4" textLength="1073.6" 
clip-path="url(#breeze-ci-selective-check-line-21)">File&#160;input&#160;(might&#160;be&#160;`-`)&#160;wi
 [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="556.8" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-22)">│</text><text 
class="breeze-ci-selective-check-r4" x="366" y="556.8" textLength="195.2" 
clip-path="url(#breeze-ci-selective-check-line-22)">--github-context</text><text
 class="breeze-ci-selective-check-r1" x="561.2" y="556.8" textLength="744.2" 
clip-path="url(#breeze-ci-selective-check-line-22)">&#160;for&#160;large&#160;contexts&#160;that&#160;would&#16
 [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="581.2" 
textLength="1464" 
clip-path="url(#breeze-ci-selective-check-line-23)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-selective-check-r1" x="1464" y="581.2" textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-23)">
-</text><text class="breeze-ci-selective-check-r5" x="0" y="605.6" 
textLength="24.4" 
clip-path="url(#breeze-ci-selective-check-line-24)">╭─</text><text 
class="breeze-ci-selective-check-r5" x="24.4" y="605.6" textLength="195.2" 
clip-path="url(#breeze-ci-selective-check-line-24)">&#160;Common&#160;options&#160;</text><text
 class="breeze-ci-selective-check-r5" x="219.6" y="605.6" textLength="1220" 
clip-path="url(#breeze-ci-selective-check-line-24)">───────────────────────────────────────────
 [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="630" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-25)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="630" textLength="109.8" 
clip-path="url(#breeze-ci-selective-check-line-25)">--verbose</text><text 
class="breeze-ci-selective-check-r7" x="158.6" y="630" textLength="24.4" 
clip-path="url(#breeze-ci-selective-check-line-25)">-v</text><text 
class="breeze-ci-selective-check-r1" x="207.4" y="630" te [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="654.4" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-26)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="654.4" textLength="109.8" 
clip-path="url(#breeze-ci-selective-check-line-26)">--dry-run</text><text 
class="breeze-ci-selective-check-r7" x="158.6" y="654.4" textLength="24.4" 
clip-path="url(#breeze-ci-selective-check-line-26)">-D</text><text 
class="breeze-ci-selective-check-r1" x="207.4" y="6 [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="678.8" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-27)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="678.8" textLength="109.8" 
clip-path="url(#breeze-ci-selective-check-line-27)">--help&#160;&#160;&#160;</text><text
 class="breeze-ci-selective-check-r7" x="158.6" y="678.8" textLength="24.4" 
clip-path="url(#breeze-ci-selective-check-line-27)">-h</text><text 
class="breeze-ci-selective-check-r1" [...]
-</text><text class="breeze-ci-selective-check-r5" x="0" y="703.2" 
textLength="1464" 
clip-path="url(#breeze-ci-selective-check-line-28)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-selective-check-r1" x="1464" y="703.2" textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-28)">
+</text><text class="breeze-ci-selective-check-r5" x="0" y="337.2" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-13)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="337.2" textLength="341.6" 
clip-path="url(#breeze-ci-selective-check-line-13)">--selective-tests-on-push&#160;&#160;&#160;</text><text
 class="breeze-ci-selective-check-r1" x="390.4" y="337.2" textLength="1049.2" 
clip-path="url(#breeze-ci-selective-check-line-13)">Select&#160;tests&#160;for&#16
 [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="361.6" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-14)">│</text><text 
class="breeze-ci-selective-check-r1" x="390.4" y="361.6" textLength="1049.2" 
clip-path="url(#breeze-ci-selective-check-line-14)">of&#160;running&#160;all&#160;tests.&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#1
 [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="386" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-15)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="386" textLength="341.6" 
clip-path="url(#breeze-ci-selective-check-line-15)">--push-base-commit&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-selective-check-r1" x="390.4" y="386" textLength="61" 
clip-path="url(#breeze-ci-selective-check-line-15)">With& [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="410.4" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-16)">│</text><text 
class="breeze-ci-selective-check-r1" x="390.4" y="410.4" textLength="427" 
clip-path="url(#breeze-ci-selective-check-line-16)">Without&#160;it,&#160;a&#160;push&#160;runs&#160;all&#160;tests.&#160;</text><text
 class="breeze-ci-selective-check-r6" x="817.4" y="410.4" textLength="73.2" 
clip-path="url(#breeze-ci-selective-check-line-16)">(TEXT) [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="434.8" 
textLength="1464" 
clip-path="url(#breeze-ci-selective-check-line-17)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-selective-check-r1" x="1464" y="434.8" textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-17)">
+</text><text class="breeze-ci-selective-check-r5" x="0" y="459.2" 
textLength="24.4" 
clip-path="url(#breeze-ci-selective-check-line-18)">╭─</text><text 
class="breeze-ci-selective-check-r5" x="24.4" y="459.2" textLength="231.8" 
clip-path="url(#breeze-ci-selective-check-line-18)">&#160;GitHub&#160;parameters&#160;</text><text
 class="breeze-ci-selective-check-r5" x="256.2" y="459.2" textLength="1183.4" 
clip-path="url(#breeze-ci-selective-check-line-18)">──────────────────────────────────────
 [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="483.6" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-19)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="483.6" textLength="268.4" 
clip-path="url(#breeze-ci-selective-check-line-19)">--github-event-name&#160;&#160;&#160;</text><text
 class="breeze-ci-selective-check-r1" x="366" y="483.6" textLength="610" 
clip-path="url(#breeze-ci-selective-check-line-19)">Name&#160;of&#160;the&#160;GitHub&#160;ev
 [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="508" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-20)">│</text><text 
class="breeze-ci-selective-check-r6" x="366" y="508" textLength="1073.6" 
clip-path="url(#breeze-ci-selective-check-line-20)">|&#160;pull_request_review&#160;|&#160;pull_request_target&#160;|&#160;pull_request_workflow&#160;|&#160;push&#160;|&#160;schedule&#160;|&#160;</text><text
 class="breeze-ci-selective-check-r5" x="1451.8" y="508" textLen [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="532.4" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-21)">│</text><text 
class="breeze-ci-selective-check-r6" x="366" y="532.4" textLength="402.6" 
clip-path="url(#breeze-ci-selective-check-line-21)">workflow_dispatch&#160;|&#160;workflow_run)</text><text
 class="breeze-ci-selective-check-r5" x="1451.8" y="532.4" textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-21)">│</text><text 
class="breeze-ci-s [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="556.8" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-22)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="556.8" textLength="268.4" 
clip-path="url(#breeze-ci-selective-check-line-22)">--github-repository&#160;&#160;&#160;</text><text
 class="breeze-ci-selective-check-r7" x="317.2" y="556.8" textLength="24.4" 
clip-path="url(#breeze-ci-selective-check-line-22)">-g</text><text 
class="breeze-ci-select [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="581.2" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-23)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="581.2" textLength="268.4" 
clip-path="url(#breeze-ci-selective-check-line-23)">--github-actor&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-selective-check-r1" x="366" y="581.2" textLength="549" 
clip-path="url(#breeze-ci-selective-check-line-23)">Actor&#160;that& [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="605.6" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-24)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="605.6" textLength="268.4" 
clip-path="url(#breeze-ci-selective-check-line-24)">--github-context&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-selective-check-r1" x="366" y="605.6" textLength="695.4" 
clip-path="url(#breeze-ci-selective-check-line-24)">GitHub&#160;context&#160 
[...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="630" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-25)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="630" textLength="268.4" 
clip-path="url(#breeze-ci-selective-check-line-25)">--github-context-input</text><text
 class="breeze-ci-selective-check-r1" x="366" y="630" textLength="1073.6" 
clip-path="url(#breeze-ci-selective-check-line-25)">File&#160;input&#160;(might&#160;be&#160;`-`)&#160;with&#16
 [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="654.4" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-26)">│</text><text 
class="breeze-ci-selective-check-r4" x="366" y="654.4" textLength="195.2" 
clip-path="url(#breeze-ci-selective-check-line-26)">--github-context</text><text
 class="breeze-ci-selective-check-r1" x="561.2" y="654.4" textLength="744.2" 
clip-path="url(#breeze-ci-selective-check-line-26)">&#160;for&#160;large&#160;contexts&#160;that&#160;would&#16
 [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="678.8" 
textLength="1464" 
clip-path="url(#breeze-ci-selective-check-line-27)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-selective-check-r1" x="1464" y="678.8" textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-27)">
+</text><text class="breeze-ci-selective-check-r5" x="0" y="703.2" 
textLength="24.4" 
clip-path="url(#breeze-ci-selective-check-line-28)">╭─</text><text 
class="breeze-ci-selective-check-r5" x="24.4" y="703.2" textLength="195.2" 
clip-path="url(#breeze-ci-selective-check-line-28)">&#160;Common&#160;options&#160;</text><text
 class="breeze-ci-selective-check-r5" x="219.6" y="703.2" textLength="1220" 
clip-path="url(#breeze-ci-selective-check-line-28)">───────────────────────────────────────────
 [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="727.6" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-29)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="727.6" textLength="109.8" 
clip-path="url(#breeze-ci-selective-check-line-29)">--verbose</text><text 
class="breeze-ci-selective-check-r7" x="158.6" y="727.6" textLength="24.4" 
clip-path="url(#breeze-ci-selective-check-line-29)">-v</text><text 
class="breeze-ci-selective-check-r1" x="207.4" y="7 [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="752" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-30)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="752" textLength="109.8" 
clip-path="url(#breeze-ci-selective-check-line-30)">--dry-run</text><text 
class="breeze-ci-selective-check-r7" x="158.6" y="752" textLength="24.4" 
clip-path="url(#breeze-ci-selective-check-line-30)">-D</text><text 
class="breeze-ci-selective-check-r1" x="207.4" y="752" te [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="776.4" 
textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-31)">│</text><text 
class="breeze-ci-selective-check-r4" x="24.4" y="776.4" textLength="109.8" 
clip-path="url(#breeze-ci-selective-check-line-31)">--help&#160;&#160;&#160;</text><text
 class="breeze-ci-selective-check-r7" x="158.6" y="776.4" textLength="24.4" 
clip-path="url(#breeze-ci-selective-check-line-31)">-h</text><text 
class="breeze-ci-selective-check-r1" [...]
+</text><text class="breeze-ci-selective-check-r5" x="0" y="800.8" 
textLength="1464" 
clip-path="url(#breeze-ci-selective-check-line-32)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-selective-check-r1" x="1464" y="800.8" textLength="12.2" 
clip-path="url(#breeze-ci-selective-check-line-32)">
 </text>
     </g>
     </g>
diff --git a/dev/breeze/doc/images/output_ci_selective-check.txt 
b/dev/breeze/doc/images/output_ci_selective-check.txt
index 625c3a3b750..f5dd36b26d9 100644
--- a/dev/breeze/doc/images/output_ci_selective-check.txt
+++ b/dev/breeze/doc/images/output_ci_selective-check.txt
@@ -1 +1 @@
-bc06116a5cbad927acd981e56b6a5411
+1b069f3d7eeb1446639dba6563dff528
diff --git a/dev/breeze/src/airflow_breeze/commands/ci_commands.py 
b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
index 887498f6355..7c822539141 100644
--- a/dev/breeze/src/airflow_breeze/commands/ci_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
@@ -194,6 +194,26 @@ def get_changed_files(commit_ref: str | None) -> 
tuple[str, ...]:
     return changed_files
 
 
+def get_changed_files_since(base_ref: str, commit_ref: str) -> tuple[str, ...] 
| None:
+    """Return the files changed between two commits, or None when they cannot 
be computed."""
+    # `--no-renames`, as `diff-tree` in `get_changed_files`: a rename counts 
as a delete plus an add,
+    # so that the tests of the old path are selected too.
+    result = run_command(
+        ["git", "diff", "--name-only", "--no-renames", base_ref, commit_ref],
+        check=False,
+        capture_output=True,
+        text=True,
+    )
+    if result.returncode != 0:
+        console_print(f"[warning] Error when running diff command 
[/]\n{result.stdout}\n{result.stderr}")
+        return None
+    changed_files = tuple(result.stdout.splitlines()) if result.stdout else ()
+    console_print(f"\n[info]Changed files since {base_ref}:[/]\n")
+    console_print(changed_files)
+    console_print()
+    return changed_files
+
+
 @ci_group.command(
     name="selective-check", help="Checks what kind of tests should be run for 
an incoming commit."
 )
@@ -248,6 +268,20 @@ def get_changed_files(commit_ref: str | None) -> 
tuple[str, ...]:
     envvar="PLATFORM",
     show_default=True,
 )
[email protected](
+    "--selective-tests-on-push",
+    help="Select tests for a push event from the changed files, as for a pull 
request, "
+    "instead of running all tests.",
+    is_flag=True,
+    envvar="SELECTIVE_TESTS_ON_PUSH",
+)
[email protected](
+    "--push-base-commit",
+    help="With --selective-tests-on-push: select tests from the files changed 
since this commit. "
+    "Without it, a push runs all tests.",
+    envvar="PUSH_BASE_COMMIT",
+    default="",
+)
 @click.option(
     "--github-context",
     help="GitHub context (JSON formatted) passed by GitHub Actions",
@@ -275,6 +309,8 @@ def selective_check(
     github_context: str,
     github_context_input: StringIO | None,
     ci_platform: str,
+    selective_tests_on_push: bool,
+    push_base_commit: str,
 ):
     try:
         from airflow_breeze.utils.selective_checks import SelectiveChecks
@@ -286,10 +322,23 @@ def selective_check(
             github_context = github_context_input.read()
         github_context_dict = json.loads(github_context) if github_context 
else {}
         github_event = GithubEvents(github_event_name)
-        if commit_ref is not None:
+        changed_files: tuple[str, ...] = ()
+        if commit_ref is not None and selective_tests_on_push and github_event 
== GithubEvents.PUSH:
+            # The base is the last successful run of the branch, so this run 
also covers the commits
+            # whose push runs failed or were cancelled by a newer push. 
Without one, nothing after the
+            # branch point is known to pass, so everything runs.
+            pushed_files = (
+                get_changed_files_since(base_ref=push_base_commit, 
commit_ref=commit_ref)
+                if push_base_commit
+                else None
+            )
+            if pushed_files is None:
+                console_print("[warning]No tested base commit for the push, 
running all tests[/]")
+                commit_ref = None
+            else:
+                changed_files = pushed_files
+        elif commit_ref is not None:
             changed_files = get_changed_files(commit_ref=commit_ref)
-        else:
-            changed_files = ()
         sc = SelectiveChecks(
             commit_ref=commit_ref,
             files=changed_files,
@@ -301,6 +350,7 @@ def selective_check(
             github_actor=github_actor,
             github_context_dict=github_context_dict,
             platform=ci_platform,
+            selective_tests_on_push=selective_tests_on_push,
         )
         print(str(sc), file=sys.stderr)
     except Exception:
@@ -346,10 +396,12 @@ class WorkflowInfo(NamedTuple):
         return PUBLIC_AMD_RUNNERS
 
     def is_canary_run(self) -> str:
+        # A push is never a canary: the CI workflows build on push only to run 
the selective tests
+        # of the pushed commit, and full runs of release branches are 
dispatched by
+        # `release-branch-ci.yml`.
         if (
             self.event_name
             in [
-                GithubEvents.PUSH.value,
                 GithubEvents.WORKFLOW_DISPATCH.value,
                 GithubEvents.SCHEDULE.value,
             ]
diff --git a/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py 
b/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py
index e7aa2259b80..f96b516018a 100644
--- a/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py
+++ b/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py
@@ -47,6 +47,8 @@ CI_PARAMETERS: dict[str, list[dict[str, str | list[str]]]] = {
                 "--default-branch",
                 "--default-constraints-branch",
                 "--platform",
+                "--selective-tests-on-push",
+                "--push-base-commit",
             ],
         },
         {
diff --git a/dev/breeze/src/airflow_breeze/utils/selective_checks.py 
b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
index 82f18b2df9d..e441fa7149f 100644
--- a/dev/breeze/src/airflow_breeze/utils/selective_checks.py
+++ b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
@@ -907,6 +907,7 @@ class SelectiveChecks:
         github_actor: str = "",
         github_context_dict: dict[str, Any] | None = None,
         platform: str = CI_AMD_PLATFORM,
+        selective_tests_on_push: bool = False,
     ):
         self._files = files
         # A changed test helper selects the tests that import it, as if those 
test files had changed;
@@ -933,6 +934,7 @@ class SelectiveChecks:
         self._github_actor = github_actor
         self._github_context_dict = github_context_dict or {}
         self._platform = platform
+        self._selective_tests_on_push = selective_tests_on_push
         self._new_toml: dict[str, Any] = {}
         self._old_toml: dict[str, Any] = {}
 
@@ -986,8 +988,15 @@ class SelectiveChecks:
     def default_constraints_branch(self) -> str:
         return self._default_constraints_branch
 
+    def _get_full_run_events(self) -> list[GithubEvents]:
+        # Only `ci-amd.yml` / `ci-arm.yml` opt in: other push-triggered 
workflows (constraints, image
+        # cache) rely on a push selecting all Python versions.
+        if self._selective_tests_on_push:
+            return [GithubEvents.SCHEDULE, GithubEvents.WORKFLOW_DISPATCH]
+        return [GithubEvents.PUSH, GithubEvents.SCHEDULE, 
GithubEvents.WORKFLOW_DISPATCH]
+
     def _should_run_all_tests_and_versions(self) -> bool:
-        if self._github_event in [GithubEvents.PUSH, GithubEvents.SCHEDULE, 
GithubEvents.WORKFLOW_DISPATCH]:
+        if self._github_event in self._get_full_run_events():
             if self.only_text_non_doc_files_changed and self._github_event == 
GithubEvents.PUSH:
                 console_print(
                     f"[warning]Only text non doc files changed in 
{self._github_event}, skip full tests[/]"
@@ -1869,8 +1878,10 @@ class SelectiveChecks:
         if len(self._matching_files(FileGroupForCi.ALL_PYPROJECT_TOML_FILES, 
CI_FILE_GROUP_MATCHES)) > 0:
             console_print("[warning]Upgrade to newer dependencies: Dependency 
files changed[/]")
             return True
-        if self._github_event == GithubEvents.SCHEDULE:
-            console_print("[warning]Upgrade to newer dependencies: Schedule 
event[/]")
+        # A dispatched run is a full run, like a scheduled canary: on release 
branches it replaces the
+        # push runs that used to update the constraints after a dependency 
backport.
+        if self._github_event in (GithubEvents.SCHEDULE, 
GithubEvents.WORKFLOW_DISPATCH):
+            console_print(f"[warning]Upgrade to newer dependencies: 
{self._github_event} event[/]")
             return True
         if UPGRADE_TO_NEWER_DEPENDENCIES_LABEL in self._pr_labels:
             console_print(
@@ -2279,7 +2290,7 @@ class SelectiveChecks:
 
     def _is_canary_run(self):
         return (
-            self._github_event in [GithubEvents.SCHEDULE, GithubEvents.PUSH, 
GithubEvents.WORKFLOW_DISPATCH]
+            self._github_event in self._get_full_run_events()
             and self._github_repository == APACHE_AIRFLOW_GITHUB_REPOSITORY
             and not self.only_text_non_doc_files_changed
         ) or CANARY_LABEL in self._pr_labels
diff --git a/dev/breeze/tests/test_ci_commands.py 
b/dev/breeze/tests/test_ci_commands.py
new file mode 100644
index 00000000000..9b70a35530f
--- /dev/null
+++ b/dev/breeze/tests/test_ci_commands.py
@@ -0,0 +1,114 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from subprocess import CompletedProcess
+from unittest.mock import patch
+
+import pytest
+from click.testing import CliRunner
+
+from airflow_breeze.commands.ci_commands import get_changed_files_since, 
selective_check
+
+PUSHED_COMMIT = "b" * 40
+BASE_COMMIT = "a" * 40
+
+
[email protected](
+    ("returncode", "stdout", "expected"),
+    [
+        pytest.param(0, "a.py\nb/c.py\n", ("a.py", "b/c.py"), id="changed 
files"),
+        pytest.param(0, "", (), id="no changes"),
+        pytest.param(128, "", None, id="git diff failed"),
+    ],
+)
+@patch("airflow_breeze.commands.ci_commands.run_command", autospec=True)
+def test_get_changed_files_since(mock_run_command, returncode, stdout, 
expected):
+    mock_run_command.return_value = CompletedProcess(args=[], 
returncode=returncode, stdout=stdout, stderr="")
+
+    assert get_changed_files_since(base_ref=BASE_COMMIT, 
commit_ref=PUSHED_COMMIT) == expected
+    assert mock_run_command.call_args.args[0] == [
+        "git",
+        "diff",
+        "--name-only",
+        "--no-renames",
+        BASE_COMMIT,
+        PUSHED_COMMIT,
+    ]
+
+
+def _invoke_selective_check(*extra_args: str):
+    return CliRunner().invoke(
+        selective_check,
+        ["--commit-ref", PUSHED_COMMIT, "--github-event-name", "push", 
*extra_args],
+        catch_exceptions=False,
+    )
+
+
[email protected](
+    ("changed_since_base", "expected_commit_ref", "expected_files"),
+    [
+        pytest.param(("a.py", "b.py"), PUSHED_COMMIT, ("a.py", "b.py"), 
id="files since the base commit"),
+        pytest.param(None, None, (), id="all tests when the diff cannot be 
computed"),
+    ],
+)
+@patch("airflow_breeze.commands.ci_commands.get_changed_files", autospec=True)
+@patch("airflow_breeze.commands.ci_commands.get_changed_files_since", 
autospec=True)
+@patch("airflow_breeze.utils.selective_checks.SelectiveChecks", autospec=True)
+def test_selective_tests_on_push_select_from_files_since_base_commit(
+    mock_selective_checks,
+    mock_get_changed_files_since,
+    mock_get_changed_files,
+    changed_since_base,
+    expected_commit_ref,
+    expected_files,
+):
+    mock_get_changed_files_since.return_value = changed_since_base
+
+    _invoke_selective_check("--selective-tests-on-push", "--push-base-commit", 
BASE_COMMIT)
+
+    mock_get_changed_files_since.assert_called_once_with(base_ref=BASE_COMMIT, 
commit_ref=PUSHED_COMMIT)
+    mock_get_changed_files.assert_not_called()
+    assert mock_selective_checks.call_args.kwargs["commit_ref"] == 
expected_commit_ref
+    assert mock_selective_checks.call_args.kwargs["files"] == expected_files
+
+
+@patch("airflow_breeze.commands.ci_commands.get_changed_files", autospec=True)
+@patch("airflow_breeze.commands.ci_commands.get_changed_files_since", 
autospec=True)
+@patch("airflow_breeze.utils.selective_checks.SelectiveChecks", autospec=True)
+def test_selective_tests_on_push_without_a_base_commit_run_all_tests(
+    mock_selective_checks, mock_get_changed_files_since, mock_get_changed_files
+):
+    _invoke_selective_check("--selective-tests-on-push")
+
+    mock_get_changed_files_since.assert_not_called()
+    mock_get_changed_files.assert_not_called()
+    assert mock_selective_checks.call_args.kwargs["commit_ref"] is None
+    assert mock_selective_checks.call_args.kwargs["files"] == ()
+
+
+@patch("airflow_breeze.commands.ci_commands.get_changed_files", autospec=True, 
return_value=("a.py",))
+@patch("airflow_breeze.commands.ci_commands.get_changed_files_since", 
autospec=True)
+@patch("airflow_breeze.utils.selective_checks.SelectiveChecks", autospec=True)
+def test_push_without_selective_tests_on_push_uses_the_pushed_commit(
+    mock_selective_checks, mock_get_changed_files_since, mock_get_changed_files
+):
+    _invoke_selective_check("--push-base-commit", BASE_COMMIT)
+
+    mock_get_changed_files_since.assert_not_called()
+    mock_get_changed_files.assert_called_once_with(commit_ref=PUSHED_COMMIT)
+    assert mock_selective_checks.call_args.kwargs["files"] == ("a.py",)
diff --git a/dev/breeze/tests/test_pr_info.py b/dev/breeze/tests/test_pr_info.py
index 2a2f814c7dd..878ef9acbe3 100644
--- a/dev/breeze/tests/test_pr_info.py
+++ b/dev/breeze/tests/test_pr_info.py
@@ -50,7 +50,7 @@ def test_push_info():
     assert wi.event_name == "push"
     assert wi.pr_number is None
     assert wi.get_runs_on() == '["ubuntu-22.04"]'
-    assert wi.is_canary_run() == "true"
+    assert wi.is_canary_run() == "false"
     assert wi.run_coverage() == "true"
 
 
diff --git a/dev/breeze/tests/test_selective_checks.py 
b/dev/breeze/tests/test_selective_checks.py
index e420bef52f4..26bc452fd24 100644
--- a/dev/breeze/tests/test_selective_checks.py
+++ b/dev/breeze/tests/test_selective_checks.py
@@ -2914,6 +2914,39 @@ def test_expected_output_push(
     assert_outputs_are_printed(expected_outputs, str(stderr))
 
 
[email protected](
+    ("selective_tests_on_push", "expected_outputs"),
+    [
+        pytest.param(
+            False,
+            {
+                "all-python-versions": ALL_PYTHON_VERSIONS_AS_LIST,
+                "full-tests-needed": "true",
+            },
+            id="Push runs all tests by default",
+        ),
+        pytest.param(
+            True,
+            {
+                "all-python-versions": 
f"['{DEFAULT_PYTHON_MAJOR_MINOR_VERSION}']",
+                "full-tests-needed": "false",
+                "run-unit-tests": "true",
+            },
+            id="Push selects tests from the changed files when selective tests 
on push are enabled",
+        ),
+    ],
+)
+def test_selective_tests_on_push(selective_tests_on_push: bool, 
expected_outputs: dict[str, str]):
+    stderr = SelectiveChecks(
+        files=("airflow-core/src/airflow/api.py",),
+        commit_ref=NEUTRAL_COMMIT,
+        github_event=GithubEvents.PUSH,
+        default_branch="v3-4-test",
+        selective_tests_on_push=selective_tests_on_push,
+    )
+    assert_outputs_are_printed(expected_outputs, str(stderr))
+
+
 @pytest.mark.parametrize(
     ("files", "expected_outputs"),
     [
diff --git a/scripts/ci/prek/check_ci_workflows_in_sync.py 
b/scripts/ci/prek/check_ci_workflows_in_sync.py
index 14d195f067b..346aaa61926 100755
--- a/scripts/ci/prek/check_ci_workflows_in_sync.py
+++ b/scripts/ci/prek/check_ci_workflows_in_sync.py
@@ -27,12 +27,12 @@ Documented divergences:
 
 1. Header intro comment (different prose explaining what each file is for)
 2. Workflow ``name:`` — ``Tests (ARM)`` vs ``Tests (AMD)``
-3. Triggers — ARM = its own schedule + push (to release-prep /
-   providers branches) + workflow_dispatch; AMD = its own schedule
-   (offset from ARM's cron) + the same push branches + pull_request +
-   workflow_dispatch. Both wrappers run on post-merge pushes to
-   stable / providers branches; only AMD runs on PRs (per-PR ARM
-   coverage stays cron-driven via the canary).
+3. Triggers — ARM = its own schedule + workflow_dispatch; AMD = its own
+   schedule (offset from ARM's cron) + pull_request + push (selective
+   tests on release-prep / providers branches) + workflow_dispatch.
+   ``release-branch-ci.yml`` dispatches the full runs of both on those
+   branches. Only AMD runs on PRs and pushes (ARM coverage stays
+   cron-driven via the canary and the dispatched release-branch runs).
 4. ``concurrency.group`` prefix — ``ci-arm-`` vs ``ci-amd-``
 5. ``build-info`` outputs ``platform`` and ``runner-type`` — hardcoded per
    architecture (and the surrounding comment naming the "ARM/AMD copy")
@@ -84,7 +84,10 @@ THIS_SCRIPT = Path(__file__).relative_to(REPO_ROOT)
 # AMD, append a rule here.
 LINE_RULES: list[tuple[str, str]] = [
     (r"^name: Tests \((?:ARM|AMD)\)$", "name: Tests (PLACEHOLDER)"),
-    (r"^  group: ci-(?:arm|amd)-", "  group: ci-PLACEHOLDER-"),
+    (
+        r"^    ci-(?:arm|amd)-\$\{\{ github\.event\.pull_request\.number \|\| 
github\.ref \}\}\$\{\{$",
+        "    ci-PLACEHOLDER-${{ github.event.pull_request.number || github.ref 
}}${{",
+    ),
     (r'^      platform: "linux/(?:arm64|amd64)"$', '      platform: 
"linux/PLACEHOLDER"'),
     (r"^      runner-type: '\[\"ubuntu-22\.04(?:-arm)?\"\]'$", "      
runner-type: 'PLACEHOLDER'"),
     (r'^          PLATFORM: "linux/(?:arm64|amd64)"$', '          PLATFORM: 
"linux/PLACEHOLDER"'),
@@ -134,6 +137,16 @@ AMD_ONLY_BLOCK = """  schedule:
       - airflow-ctl/v[0-9]+-[0-9]+-test
       - airflow-ctl/v[0-9]+-[0-9]+-stable
     types: [opened, reopened, synchronize, ready_for_review]
+  push:
+    # Pushes to release-prep / providers branches run only the tests needed by 
the changes since the
+    # last successful run of the branch (selected as for a PR). Full runs of 
these branches, on AMD
+    # and ARM, are dispatched by `release-branch-ci.yml`. `main` does not 
build on push; its full
+    # runs are the canary above.
+    branches:
+      - v[0-9]+-[0-9]+-test
+      - providers-[a-z]+-?[a-z]*/v[0-9]+-[0-9]+
+      - chart/v[0-9]+-[0-9]+x-test
+      - airflow-ctl/v[0-9]+-[0-9]+-test
 """
 
 

Reply via email to