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

shahar1 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 b5bc253fc2d Run maintainer and canary CI on self-hosted RunsOn runners 
(#74889)
b5bc253fc2d is described below

commit b5bc253fc2d594ccedb7da6104f2b03d26485651
Author: Shahar Epstein <[email protected]>
AuthorDate: Sun Oct 25 10:48:19 2026 +0200

    Run maintainer and canary CI on self-hosted RunsOn runners (#74889)
    
    This PR implements AIP-118 for integrating self-hosted runners in Apache 
Airflow's CI, utilizing the project's AWS instance and runs-on.com's runners.
---
 .github/actions/breeze/action.yml                  |   2 +
 .../actions/prepare_breeze_and_image/action.yml    |   5 +-
 .github/actions/prepare_single_ci_image/action.yml |   5 +-
 .../actions/runs-on-cost-summary/action.yml        |  32 +-
 .github/actions/stash-restore/action.yml           | 137 +++++++
 .github/actions/stash-save/action.yml              | 129 +++++++
 .github/workflows/additional-ci-image-checks.yml   |   2 +-
 .github/workflows/additional-prod-image-tests.yml  |  30 +-
 .github/workflows/basic-tests.yml                  |  26 +-
 .github/workflows/ci-amd.yml                       |  61 +--
 .github/workflows/ci-arm.yml                       |  63 ++--
 .github/workflows/ci-image-build.yml               |  36 +-
 .github/workflows/ci-image-checks.yml              |   8 +-
 .github/workflows/finalize-tests.yml               |   6 +-
 .github/workflows/generate-constraints.yml         |   4 +-
 .github/workflows/helm-tests.yml                   |   4 +-
 .github/workflows/integration-system-tests.yml     |   6 +-
 .github/workflows/k8s-tests.yml                    |   4 +-
 .github/workflows/kustomize-overlays-tests.yml     |   2 +-
 .github/workflows/prod-image-build.yml             |   6 +-
 .github/workflows/push-image-cache.yml             |   4 +-
 .github/workflows/special-tests.yml                |  22 +-
 .github/workflows/test-providers.yml               |   4 +-
 .github/workflows/ui-e2e-tests.yml                 |   2 +-
 README.md                                          |   2 +
 dev/breeze/doc/ci/04_selective_checks.md           |  90 ++++-
 dev/breeze/doc/ci/06_debugging.md                  |   6 +-
 dev/breeze/src/airflow_breeze/global_constants.py  |   8 +
 .../src/airflow_breeze/utils/selective_checks.py   | 116 ++++++
 dev/breeze/tests/test_selective_checks.py          | 411 +++++++++++++++++++++
 scripts/ci/make_mnt_writeable.sh                   |  21 +-
 scripts/ci/move_docker_to_mnt.sh                   |   7 +-
 scripts/ci/prek/check_ci_workflows_in_sync.py      |   5 +-
 33 files changed, 1096 insertions(+), 170 deletions(-)

diff --git a/.github/actions/breeze/action.yml 
b/.github/actions/breeze/action.yml
index cb49d1d6d57..9fe5b551096 100644
--- a/.github/actions/breeze/action.yml
+++ b/.github/actions/breeze/action.yml
@@ -29,6 +29,8 @@ outputs:
 runs:
   using: "composite"
   steps:
+    - name: "RunsOn cost summary"
+      uses: ./.github/actions/runs-on-cost-summary
     - name: "Setup python"
       uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # 
v6.2.0
       with:
diff --git a/.github/actions/prepare_breeze_and_image/action.yml 
b/.github/actions/prepare_breeze_and_image/action.yml
index 6c91e28bff8..263db0346d4 100644
--- a/.github/actions/prepare_breeze_and_image/action.yml
+++ b/.github/actions/prepare_breeze_and_image/action.yml
@@ -82,12 +82,13 @@ runs:
     - name: >
         Restore ${{ inputs.image-type }} docker image ${{ inputs.platform 
}}:${{ inputs.python }}
         ${{ inputs.image-stash-ref != '' && format('built for ref {0}', 
inputs.image-stash-ref) || '' }}
-      # yamllint disable-line rule:line-length
-      uses: 
apache/infrastructure-actions/stash/restore@61dcea11f19e2bbe1263f14d72235e8da17d3ad0
  # restore/v1.0.0
+      uses: ./.github/actions/stash-restore
       with:
         key: "${{ inputs.image-type }}-image-save-v3-${{ inputs.platform 
}}-${{ inputs.python }}\
           ${{ inputs.image-stash-ref != '' && format('-{0}', 
inputs.image-stash-ref) || '' }}"
         path: "/mnt/"
+        # Must stay true: runs on RunsOn (canaries and release-branch pushes) 
save these image
+        # stashes as S3 digests only.
         only-current-branch: 'true'
         fail-on-download: 'true'
     - name: "Load ${{ inputs.image-type }} image ${{ inputs.platform }}:${{ 
inputs.python }}"
diff --git a/.github/actions/prepare_single_ci_image/action.yml 
b/.github/actions/prepare_single_ci_image/action.yml
index d70012c80f7..8a734ef1102 100644
--- a/.github/actions/prepare_single_ci_image/action.yml
+++ b/.github/actions/prepare_single_ci_image/action.yml
@@ -36,11 +36,12 @@ runs:
   using: "composite"
   steps:
     - name: "Restore CI docker images ${{ inputs.platform }}:${{ inputs.python 
}}"
-      # yamllint disable-line rule:line-length
-      uses: 
apache/infrastructure-actions/stash/restore@61dcea11f19e2bbe1263f14d72235e8da17d3ad0
  # restore/v1.0.0
+      uses: ./.github/actions/stash-restore
       with:
         key: ci-image-save-v3-${{ inputs.platform }}-${{ inputs.python }}
         path: "/mnt/"
+        # Must stay true: runs on RunsOn (canaries and release-branch pushes) 
save these image
+        # stashes as S3 digests only.
         only-current-branch: 'true'
         fail-on-download: 'true'
       if: contains(inputs.python-versions-list-as-string, inputs.python)
diff --git a/scripts/ci/make_mnt_writeable.sh 
b/.github/actions/runs-on-cost-summary/action.yml
old mode 100755
new mode 100644
similarity index 58%
copy from scripts/ci/make_mnt_writeable.sh
copy to .github/actions/runs-on-cost-summary/action.yml
index ddabf84b6b7..fd0d2007f10
--- a/scripts/ci/make_mnt_writeable.sh
+++ b/.github/actions/runs-on-cost-summary/action.yml
@@ -1,4 +1,3 @@
-#!/usr/bin/env bash
 # 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
@@ -15,20 +14,17 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-function make_mnt_writeable {
-    set -x
-    echo "Investigating node disks"
-    lsblk
-    sudo blkid
-    echo "Checking free space!"
-    df -H
-    echo "Cleaning /mnt just in case it is not empty"
-    sudo mkdir -p /mnt
-    sudo rm -rf /mnt/*
-    echo "Checking free space!"
-    df -H
-    echo "Making sure that /mnt is writeable"
-    sudo chown -R "${USER}" /mnt
-}
-
-make_mnt_writeable
+#
+---
+# Puts the job's EC2 cost next to the GitHub-hosted price in the job summary. 
Used by every job
+# that can run on RunsOn: through the breeze action, or directly by jobs that 
do not install Breeze.
+name: 'RunsOn cost summary'
+description: 'Add the EC2 cost of a RunsOn job to its summary; a no-op 
elsewhere.'
+runs:
+  using: 'composite'
+  steps:
+    - name: "RunsOn cost summary"
+      if: runner.environment == 'self-hosted'
+      uses: runs-on/action@17b59092eb37ebfdbb034460892887c09100eaa3  # v2.5.0
+      with:
+        show_costs: summary
diff --git a/.github/actions/stash-restore/action.yml 
b/.github/actions/stash-restore/action.yml
new file mode 100644
index 00000000000..ed9535c1722
--- /dev/null
+++ b/.github/actions/stash-restore/action.yml
@@ -0,0 +1,137 @@
+# 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.
+#
+---
+# apache/infrastructure-actions/stash/restore on GitHub-hosted runners. On 
RunsOn (the only
+# self-hosted runners) multi-GB image tarballs stay in the RunsOn stack's 
in-region S3 cache
+# bucket instead of leaving AWS as GitHub artifacts.
+#
+# Every RunsOn job can write anywhere in that bucket, so the bucket is not 
trusted: objects are
+# named by their sha256, and the digest itself travels as a small ASF stash 
artifact, which
+# GitHub scopes to the run / branch exactly like a regular stash. A blob that 
does not match the
+# digest the ASF restore found for this run, branch and repository is never 
extracted. The bucket's
+# lifecycle rule (S3CacheExpirationInDays) must outlive the longest 
retention-days of a digest
+# (6 days today), or a digest hit finds no blob.
+name: 'Restore stash'
+description: 'Restore a stash from GitHub artifacts, or from S3 on RunsOn 
runners.'
+inputs:
+  key:
+    description: 'Stash key.'
+    required: true
+  path:
+    description: >
+      Directory to restore into (GitHub-hosted only; the S3 tarball restores 
entries to the
+      paths they were saved from, and the restore then fails if any of them is 
outside this directory).
+    required: false
+    default: ${{ github.workspace }}
+  only-current-branch:
+    description: 'Do not fall back to the base branch stash.'
+    required: false
+    default: 'false'
+  fail-on-download:
+    description: 'Fail when a stash was found but could not be restored.'
+    required: false
+    default: 'false'
+outputs:
+  stash-hit:
+    description: "'true' if a stash was restored, 'false' otherwise."
+    value: ${{ steps.s3.outputs.stash-hit || steps.artifact.outputs.stash-hit 
}}
+runs:
+  using: 'composite'
+  steps:
+    - name: "Restore stash from GitHub artifacts"
+      id: artifact
+      if: runner.environment != 'self-hosted'
+      # yamllint disable-line rule:line-length
+      uses: 
apache/infrastructure-actions/stash/restore@c968777141a2afc98d1a09c9f6979455bfebaae3
  # restore/v1.0.1
+      with:
+        key: ${{ inputs.key }}
+        path: ${{ inputs.path }}
+        only-current-branch: ${{ inputs.only-current-branch }}
+        fail-on-download: ${{ inputs.fail-on-download }}
+    - name: "Restore stash digest from GitHub artifacts"
+      id: digest
+      if: runner.environment == 'self-hosted'
+      # yamllint disable-line rule:line-length
+      uses: 
apache/infrastructure-actions/stash/restore@c968777141a2afc98d1a09c9f6979455bfebaae3
  # restore/v1.0.1
+      with:
+        key: ${{ inputs.key }}-s3
+        path: ${{ runner.temp }}/stash-digest-restore/${{ inputs.key }}
+        only-current-branch: ${{ inputs.only-current-branch }}
+        fail-on-download: ${{ inputs.fail-on-download }}
+    - name: "Restore stash from S3"
+      id: s3
+      if: runner.environment == 'self-hosted'
+      shell: bash
+      env:
+        STASH_KEY: ${{ inputs.key }}
+        STASH_PATH: ${{ inputs.path }}
+        DIGEST_HIT: ${{ steps.digest.outputs.stash-hit }}
+        DIGEST_FILE: ${{ runner.temp }}/stash-digest-restore/${{ inputs.key 
}}/sha256
+        FAIL_ON_DOWNLOAD: ${{ inputs.fail-on-download }}
+      run: |
+        set -uo pipefail
+        payload="${RUNNER_TEMP}/stash-restore-$$.tar"
+        entries="${RUNNER_TEMP}/stash-restore-$$.entries"
+        miss() {
+          rm -f "${payload}" "${entries}"
+          echo "stash-hit=false" >> "${GITHUB_OUTPUT}"
+          if [[ "${FAIL_ON_DOWNLOAD}" == "true" && "${DIGEST_HIT}" == "true" 
]]; then
+            echo "::error::$1"
+            exit 1
+          fi
+          echo "$1"
+          exit 0
+        }
+        if [[ "${DIGEST_HIT}" != "true" ]]; then
+          miss "No stash '${STASH_KEY}'"
+        fi
+        digest="$(cat "${DIGEST_FILE}")"
+        if [[ ! "${digest}" =~ ^[0-9a-f]{64}$ ]]; then
+          miss "Malformed digest for stash '${STASH_KEY}'"
+        fi
+        bucket="${RUNS_ON_S3_BUCKET_CACHE:?not running on a RunsOn runner}"
+        export AWS_DEFAULT_REGION="${RUNS_ON_AWS_REGION:?not running on a 
RunsOn runner}"
+        object="s3://${bucket}/cache/airflow-stash/sha256/${digest}.tar"
+        if ! aws s3 cp --only-show-errors "${object}" "${payload}"; then
+          miss "Could not download ${object} for stash '${STASH_KEY}'"
+        fi
+        if ! echo "${digest}  ${payload}" | sha256sum --check --status; then
+          miss "${object} does not match the digest recorded for stash 
'${STASH_KEY}'"
+        fi
+        # GNU tar detects the compression stash-save chose.
+        if ! tar -xPvf "${payload}" > "${entries}"; then
+          miss "Could not extract ${object} for stash '${STASH_KEY}'"
+        fi
+        rm -f "${payload}"
+        # The S3 tarball ignores `path`: fail loudly if a caller saves and 
restores different
+        # directories, which would work on GitHub-hosted runners only.
+        restore_dir="$(realpath -ms "${STASH_PATH}")"
+        restore_dir="${restore_dir%/}"
+        outside=()
+        while IFS= read -r entry; do
+          [[ "${entry}" == /* ]] || entry="${GITHUB_WORKSPACE}/${entry}"
+          [[ "${entry}" == "${restore_dir}/"* ]] || outside+=("${entry}")
+        done < "${entries}"
+        rm -f "${entries}"
+        if [[ ${#outside[@]} -gt 0 ]]; then
+          printf '%s\n' "${outside[@]}"
+          echo "::error::Stash '${STASH_KEY}' restored the entries above 
outside of ${restore_dir}/"
+          exit 1
+        fi
+        echo "stash-hit=true" >> "${GITHUB_OUTPUT}"
+        echo "Restored stash '${STASH_KEY}' from ${object}"
diff --git a/.github/actions/stash-save/action.yml 
b/.github/actions/stash-save/action.yml
new file mode 100644
index 00000000000..ecfd2409438
--- /dev/null
+++ b/.github/actions/stash-save/action.yml
@@ -0,0 +1,129 @@
+# 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.
+#
+---
+# apache/infrastructure-actions/stash/save on GitHub-hosted runners. On RunsOn 
the tarball goes
+# to the stack's S3 cache bucket under its sha256 and only the digest is saved 
as an ASF stash
+# artifact. See stash-restore for why.
+name: 'Save stash'
+description: 'Save a stash to GitHub artifacts, or to S3 on RunsOn runners.'
+inputs:
+  key:
+    description: 'Stash key.'
+    required: true
+  path:
+    description: 'File, directory or glob to stash.'
+    required: true
+  if-no-files-found:
+    description: "'error' fails when path matches nothing, 'warn' or 'ignore' 
do not."
+    required: false
+    default: 'warn'
+  retention-days:
+    description: >
+      Days the stash (on RunsOn: its digest) is kept. On RunsOn the stack's
+      S3CacheExpirationInDays lifecycle rule expires the tarball itself.
+    required: false
+    default: '5'
+  compression-level:
+    description: "Zlib compression level (0-9). On RunsOn, '0' stores the 
tarball uncompressed."
+    required: false
+    default: '6'
+runs:
+  using: 'composite'
+  steps:
+    - name: "Save stash to GitHub artifacts"
+      if: runner.environment != 'self-hosted'
+      uses: 
apache/infrastructure-actions/stash/save@7d3afde57801d8c9a2db9730c28ae4e036cc2ab9
  # save/v1.0.2
+      with:
+        key: ${{ inputs.key }}
+        path: ${{ inputs.path }}
+        if-no-files-found: ${{ inputs.if-no-files-found }}
+        retention-days: ${{ inputs.retention-days }}
+        compression-level: ${{ inputs.compression-level }}
+    - name: "Save stash to S3"
+      id: s3
+      if: runner.environment == 'self-hosted'
+      shell: bash
+      env:
+        STASH_KEY: ${{ inputs.key }}
+        STASH_PATH: ${{ inputs.path }}
+        DIGEST_DIR: ${{ runner.temp }}/stash-digest-save/${{ inputs.key }}
+        IF_NO_FILES_FOUND: ${{ inputs.if-no-files-found }}
+        COMPRESSION_LEVEL: ${{ inputs.compression-level }}
+      run: |
+        set -euo pipefail
+        bucket="${RUNS_ON_S3_BUCKET_CACHE:?not running on a RunsOn runner}"
+        export AWS_DEFAULT_REGION="${RUNS_ON_AWS_REGION:?not running on a 
RunsOn runner}"
+        shopt -s nullglob
+        # shellcheck disable=SC2206
+        candidates=( ${STASH_PATH} )
+        existing=()
+        for p in "${candidates[@]}"; do
+          if [[ -e "${p}" ]]; then
+            existing+=("${p}")
+          fi
+        done
+        if [[ ${#existing[@]} -eq 0 ]]; then
+          if [[ "${IF_NO_FILES_FOUND}" == "error" ]]; then
+            echo "::error::No files found for path '${STASH_PATH}'"
+            exit 1
+          fi
+          if [[ "${IF_NO_FILES_FOUND}" == "warn" ]]; then
+            echo "::warning::No files found for path '${STASH_PATH}', skipping 
save"
+          fi
+          exit 0
+        fi
+        # The tarball is streamed to S3 while being hashed: a multi-GB image 
never touches the
+        # runner's disk. Its key is only known at the end, so it lands under a 
temporary key and
+        # is copied server-side to its digest key.
+        prefix="s3://${bucket}/cache/airflow-stash"
+        upload="${prefix}/uploads/$(cat /proc/sys/kernel/random/uuid).tar"
+        digest_fifo="${RUNNER_TEMP}/stash-save-$$.fifo"
+        digest_file="${RUNNER_TEMP}/stash-save-$$.sha256"
+        trap 'rm -f "${digest_fifo}" "${digest_file}"' EXIT
+        mkfifo "${digest_fifo}"
+        sha256sum < "${digest_fifo}" | cut -d ' ' -f 1 > "${digest_file}" &
+        digest_pid=$!
+        compression=()
+        if [[ "${COMPRESSION_LEVEL}" != "0" ]]; then
+          compression=(-z)
+        fi
+        tar -cP "${compression[@]}" -f - "${existing[@]}" \
+          | tee "${digest_fifo}" \
+          | aws s3 cp --only-show-errors - "${upload}"
+        wait "${digest_pid}"
+        digest="$(cat "${digest_file}")"
+        if [[ ! "${digest}" =~ ^[0-9a-f]{64}$ ]]; then
+          echo "::error::Could not hash the stash '${STASH_KEY}'"
+          exit 1
+        fi
+        object="${prefix}/sha256/${digest}.tar"
+        aws s3 cp --only-show-errors --copy-props none "${upload}" "${object}"
+        # Leaving the upload behind only costs storage until the bucket's 
lifecycle rule expires it.
+        aws s3 rm --only-show-errors "${upload}" || echo "::warning::Could not 
remove ${upload}"
+        mkdir -p "${DIGEST_DIR}"
+        echo "${digest}" > "${DIGEST_DIR}/sha256"
+        echo "saved=true" >> "${GITHUB_OUTPUT}"
+        echo "Saved ${existing[*]} to ${object}"
+    - name: "Save stash digest to GitHub artifacts"
+      if: runner.environment == 'self-hosted' && steps.s3.outputs.saved == 
'true'
+      uses: 
apache/infrastructure-actions/stash/save@7d3afde57801d8c9a2db9730c28ae4e036cc2ab9
  # save/v1.0.2
+      with:
+        key: ${{ inputs.key }}-s3
+        path: ${{ runner.temp }}/stash-digest-save/${{ inputs.key }}
+        if-no-files-found: error
+        retention-days: ${{ inputs.retention-days }}
diff --git a/.github/workflows/additional-ci-image-checks.yml 
b/.github/workflows/additional-ci-image-checks.yml
index de26e37fbfc..7d60cf6ef41 100644
--- a/.github/workflows/additional-ci-image-checks.yml
+++ b/.github/workflows/additional-ci-image-checks.yml
@@ -62,7 +62,7 @@ jobs:
     # layer that builds Python from source, which takes 11-18 minutes for 
Python 3.11+ on hosted runners.
     timeout-minutes: 35
     name: Check that image builds quickly
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     env:
       UPGRADE_TO_NEWER_DEPENDENCIES: false
       PYTHON_MAJOR_MINOR_VERSION: "${{ inputs.default-python-version }}"
diff --git a/.github/workflows/additional-prod-image-tests.yml 
b/.github/workflows/additional-prod-image-tests.yml
index dc5da4b211e..c850b783deb 100644
--- a/.github/workflows/additional-prod-image-tests.yml
+++ b/.github/workflows/additional-prod-image-tests.yml
@@ -152,7 +152,7 @@ jobs:
   test-examples-of-prod-image-building:
     timeout-minutes: 60
     name: "Test examples of PROD image building"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 4, 16) }}
     env:
       GITHUB_REPOSITORY: ${{ github.repository }}
       GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -189,7 +189,7 @@ jobs:
   test-docker-compose-quick-start:
     timeout-minutes: 60
     name: "Docker Compose quick start with PROD image verifying"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 32, 128) }}
     env:
       PYTHON_MAJOR_MINOR_VERSION: "${{ inputs.default-python-version }}"
       GITHUB_REPOSITORY: ${{ github.repository }}
@@ -220,7 +220,7 @@ jobs:
   task-sdk-integration-tests:
     timeout-minutes: 60
     name: "Task SDK integration tests with PROD image"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 4, 16) }}
     env:
       PYTHON_MAJOR_MINOR_VERSION: "${{ inputs.default-python-version }}"
       GITHUB_REPOSITORY: ${{ github.repository }}
@@ -254,7 +254,7 @@ jobs:
     uses: ./.github/workflows/airflow-e2e-tests.yml
     with:
       workflow-name: "Regular e2e test"
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 8, 32) }}
       platform: ${{ inputs.platform }}
       default-python-version: "${{ inputs.default-python-version }}"
       use-uv: ${{ inputs.use-uv }}
@@ -264,7 +264,7 @@ jobs:
     uses: ./.github/workflows/airflow-e2e-tests.yml
     with:
       workflow-name: "Remote logging e2e test"
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 4, 16) }}
       platform: ${{ inputs.platform }}
       default-python-version: "${{ inputs.default-python-version }}"
       use-uv: ${{ inputs.use-uv }}
@@ -276,7 +276,7 @@ jobs:
     uses: ./.github/workflows/airflow-e2e-tests.yml
     with:
       workflow-name: "Elasticsearch remote logging e2e test"
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 8, 32) }}
       platform: ${{ inputs.platform }}
       default-python-version: "${{ inputs.default-python-version }}"
       use-uv: ${{ inputs.use-uv }}
@@ -288,7 +288,7 @@ jobs:
     uses: ./.github/workflows/airflow-e2e-tests.yml
     with:
       workflow-name: "OpenSearch remote logging e2e test"
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 8, 32) }}
       platform: ${{ inputs.platform }}
       default-python-version: "${{ inputs.default-python-version }}"
       use-uv: ${{ inputs.use-uv }}
@@ -300,7 +300,7 @@ jobs:
     uses: ./.github/workflows/airflow-e2e-tests.yml
     with:
       workflow-name: "XCom object storage backend e2e test"
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 4, 16) }}
       platform: ${{ inputs.platform }}
       default-python-version: "${{ inputs.default-python-version }}"
       use-uv: ${{ inputs.use-uv }}
@@ -311,7 +311,7 @@ jobs:
     uses: ./.github/workflows/airflow-e2e-tests.yml
     with:
       workflow-name: "Event driven e2e test"
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 32, 128) }}
       platform: ${{ inputs.platform }}
       default-python-version: "${{ inputs.default-python-version }}"
       use-uv: ${{ inputs.use-uv }}
@@ -323,7 +323,7 @@ jobs:
     uses: ./.github/workflows/airflow-e2e-tests.yml
     with:
       workflow-name: "Java SDK e2e test"
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 8, 32) }}
       platform: ${{ inputs.platform }}
       default-python-version: "${{ inputs.default-python-version }}"
       use-uv: ${{ inputs.use-uv }}
@@ -335,7 +335,7 @@ jobs:
     uses: ./.github/workflows/airflow-e2e-tests.yml
     with:
       workflow-name: "Go SDK e2e test"
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 32, 128) }}
       platform: ${{ inputs.platform }}
       default-python-version: "${{ inputs.default-python-version }}"
       use-uv: ${{ inputs.use-uv }}
@@ -347,7 +347,7 @@ jobs:
     uses: ./.github/workflows/airflow-e2e-tests.yml
     with:
       workflow-name: "TypeScript SDK e2e test"
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 32, 128) }}
       platform: ${{ inputs.platform }}
       default-python-version: "${{ inputs.default-python-version }}"
       use-uv: ${{ inputs.use-uv }}
@@ -359,7 +359,7 @@ jobs:
     uses: ./.github/workflows/airflow-e2e-tests.yml
     with:
       workflow-name: "OpenLineage e2e test"
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 8, 32) }}
       platform: ${{ inputs.platform }}
       default-python-version: "${{ inputs.default-python-version }}"
       use-uv: ${{ inputs.use-uv }}
@@ -377,7 +377,7 @@ jobs:
         compat: ${{ fromJSON(inputs.providers-compatibility-tests-matrix) }}
     uses: ./.github/workflows/openlineage-e2e-compat-tests.yml
     with:
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 8, 32) }}
       platform: ${{ inputs.platform }}
       default-python-version: "${{ inputs.default-python-version }}"
       use-uv: ${{ inputs.use-uv }}
@@ -423,7 +423,7 @@ jobs:
   airflow-ctl-integration-tests:
     timeout-minutes: 60
     name: "Airflow CTL integration tests with PROD image"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     env:
       PYTHON_MAJOR_MINOR_VERSION: "${{ inputs.default-python-version }}"
       GITHUB_REPOSITORY: ${{ github.repository }}
diff --git a/.github/workflows/basic-tests.yml 
b/.github/workflows/basic-tests.yml
index 4212f4bd872..88740c02c46 100644
--- a/.github/workflows/basic-tests.yml
+++ b/.github/workflows/basic-tests.yml
@@ -87,7 +87,7 @@ jobs:
   run-breeze-tests:
     timeout-minutes: 10
     name: Breeze unit tests
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 4, 16) }}
     steps:
       - name: "Cleanup repo"
         shell: bash
@@ -105,7 +105,7 @@ jobs:
   run-breeze-integration-tests:
     timeout-minutes: 120
     name: Breeze integration tests
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 4, 16) }}
     if: inputs.run-breeze-integration-tests == 'true'
     steps:
       - name: "Cleanup repo"
@@ -136,13 +136,15 @@ jobs:
       # Without a pin uv picks the runner's system Python, which can be older 
than Airflow supports.
       UV_PYTHON: "${{ inputs.default-python-version }}"
       SHARED_DISTRIBUTIONS: ${{ 
join(fromJSON(inputs.shared-distributions-as-json), ' ') }}
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     steps:
       - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
         with:
           fetch-depth: 1
           persist-credentials: false
+      - name: "RunsOn cost summary"
+        uses: ./.github/actions/runs-on-cost-summary
       - name: "Install uv"
         # Extract uv version from uv.lock. The format is stable: the line
         # immediately after `name = "uv"` is `version = "<X.Y.Z>"`.
@@ -171,7 +173,7 @@ jobs:
   tests-scripts:
     timeout-minutes: 10
     name: Scripts tests
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 4, 16) }}
     if: inputs.run-scripts-tests == 'true'
     env:
       # Without a pin uv picks the runner's system Python, which can be older 
than Airflow supports.
@@ -182,6 +184,8 @@ jobs:
         with:
           fetch-depth: 1
           persist-credentials: false
+      - name: "RunsOn cost summary"
+        uses: ./.github/actions/runs-on-cost-summary
       - name: "Install uv"
         # Extract uv version from uv.lock. The format is stable: the line
         # immediately after `name = "uv"` is `version = "<X.Y.Z>"`.
@@ -200,7 +204,7 @@ jobs:
   tests-ui:
     timeout-minutes: 15
     name: React UI tests
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     if: inputs.run-ui-tests == 'true'
     steps:
       - name: "Cleanup repo"
@@ -210,6 +214,8 @@ jobs:
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
         with:
           persist-credentials: false
+      - name: "RunsOn cost summary"
+        uses: ./.github/actions/runs-on-cost-summary
       - name: Setup pnpm
         uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413  # 
v6.1.0
         with:
@@ -237,7 +243,7 @@ jobs:
   check-translation-completness:
     timeout-minutes: 15
     name: "Check translation completeness"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 32, 128) }}
     steps:
       - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
@@ -254,7 +260,7 @@ jobs:
   static-checks-basic-checks-only:
     timeout-minutes: 30
     name: "Static checks: basic checks only"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     if: inputs.basic-checks-only == 'true'
     steps:
       - name: "Cleanup repo"
@@ -327,7 +333,7 @@ jobs:
   test-airflow-release-commands:
     timeout-minutes: 80
     name: "Test Airflow release commands"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     env:
       PYTHON_MAJOR_MINOR_VERSION: "${{ inputs.default-python-version }}"
       GITHUB_REPOSITORY: ${{ github.repository }}
@@ -381,7 +387,7 @@ jobs:
   test-airflow-standalone:
     timeout-minutes: 30
     name: "Test Airflow standalone commands"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 4, 16) }}
     env:
       AIRFLOW_HOME: ~/airflow
       FORCE_COLOR: 1
@@ -390,6 +396,8 @@ jobs:
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
         with:
           persist-credentials: false
+      - name: "RunsOn cost summary"
+        uses: ./.github/actions/runs-on-cost-summary
       - name: "Install uv"
         # Extract uv version from uv.lock. The format is stable: the line
         # immediately after `name = "uv"` is `version = "<X.Y.Z>"`.
diff --git a/.github/workflows/ci-amd.yml b/.github/workflows/ci-amd.yml
index 92f1903a1b7..05fa7900c0f 100644
--- a/.github/workflows/ci-amd.yml
+++ b/.github/workflows/ci-amd.yml
@@ -20,8 +20,9 @@
 # `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
-# `build-info`'s outputs, and the architecture-specific print-platform job.
+# trigger surface, the hardcoded `platform` value in `build-info`'s outputs
+# (and the `PLATFORM` selective checks pick the runner type for), and the
+# architecture-specific print-platform job.
 # Edit both files together when changing pipeline shape.
 name: Tests (AMD)
 on:  # yamllint disable-line rule:truthy
@@ -305,7 +306,7 @@ jobs:
       latest-versions-only: ${{ 
steps.selective-checks.outputs.latest-versions-only }}
       mysql-exclude: ${{ steps.selective-checks.outputs.mysql-exclude }}
       mysql-versions: ${{ steps.selective-checks.outputs.mysql-versions }}
-      # platform / runner-type are hardcoded per workflow file (this is the
+      # platform is hardcoded per workflow file (this is the
       # AMD copy). The matching ARM copy lives in ci-arm.yml.
       platform: "linux/amd64"
       postgres-exclude: ${{ steps.selective-checks.outputs.postgres-exclude }}
@@ -347,7 +348,7 @@ jobs:
       run-task-sdk-integration-tests: ${{ 
steps.selective-checks.outputs.run-task-sdk-integration-tests }}
       run-breeze-integration-tests: ${{ 
steps.selective-checks.outputs.run-breeze-integration-tests }}
       run-scripts-tests: ${{ steps.selective-checks.outputs.run-scripts-tests 
}}
-      runner-type: '["ubuntu-22.04"]'
+      runner-type: ${{ steps.selective-checks.outputs.runner-type }}
       run-ui-tests: ${{ steps.selective-checks.outputs.run-ui-tests }}
       run-ui-e2e-tests: ${{ steps.selective-checks.outputs.run-ui-e2e-tests }}
       run-unit-tests: ${{ steps.selective-checks.outputs.run-unit-tests }}
@@ -429,6 +430,8 @@ jobs:
           COMMIT_REF: "${{ github.sha }}"
           VERBOSE: "false"
           GITHUB_CONTEXT_INPUT: "${{ runner.temp }}/github_context.json"
+          # Repository variable: "true" moves every run, pushes included, back 
to public runners.
+          USE_PUBLIC_RUNNERS: "${{ vars.USE_PUBLIC_RUNNERS }}"
           PLATFORM: "linux/amd64"
           SELECTIVE_TESTS_ON_PUSH: "true"
           PUSH_BASE_COMMIT: ${{ 
needs.check-run-needed.outputs.push-base-commit }}
@@ -566,7 +569,7 @@ jobs:
     timeout-minutes: 45
     name: "MyPy providers checks"
     needs: [build-info, build-ci-images]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 4, 
16) }}
     if: needs.build-info.outputs.run-mypy-providers == 'true'
     env:
       PYTHON_MAJOR_MINOR_VERSION: "${{ 
needs.build-info.outputs.default-python-version }}"
@@ -607,7 +610,7 @@ jobs:
     timeout-minutes: 20
     name: "Migration round-trip check"
     needs: [build-info, build-ci-images]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 8, 
32) }}
     if: needs.build-info.outputs.has-migrations == 'true'
     env:
       PYTHON_MAJOR_MINOR_VERSION: "${{ 
needs.build-info.outputs.default-python-version }}"
@@ -729,7 +732,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "postgres"
       test-name: "Postgres"
@@ -758,7 +761,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "postgres"
       test-name: "Postgres"
@@ -787,7 +790,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "mysql"
       test-name: "MySQL"
@@ -817,7 +820,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "mysql"
       test-name: "MySQL"
@@ -846,7 +849,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "sqlite"
       test-name: "Sqlite"
@@ -877,7 +880,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "sqlite"
       test-name: "Sqlite"
@@ -908,7 +911,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 32, 128) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "sqlite"
       test-name: ""
@@ -938,7 +941,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 32, 128) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "sqlite"
       test-name: ""
@@ -1024,7 +1027,7 @@ jobs:
     if: >
       needs.build-info.outputs.run-unit-tests == 'true'
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 32, 128) }}
       platform: ${{ needs.build-info.outputs.platform }}
       test-name: "LowestDeps"
       force-lowest-dependencies: "true"
@@ -1054,7 +1057,7 @@ jobs:
       packages: read
     if: needs.build-info.outputs.run-unit-tests == 'true'
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 32, 128) }}
       platform: ${{ needs.build-info.outputs.platform }}
       test-name: "LowestDeps"
       force-lowest-dependencies: "true"
@@ -1165,7 +1168,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 32, 128) }}
       platform: ${{ needs.build-info.outputs.platform }}
       default-python-version: "${{ 
needs.build-info.outputs.default-python-version }}"
       python-versions: ${{ needs.build-info.outputs.python-versions }}
@@ -1181,7 +1184,7 @@ jobs:
   tests-go-sdk:
     name: "Go SDK tests"
     needs: [build-info]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 8, 
32) }}
     timeout-minutes: 15
     permissions:
       contents: read
@@ -1197,6 +1200,8 @@ jobs:
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
         with:
           persist-credentials: false
+      - name: "RunsOn cost summary"
+        uses: ./.github/actions/runs-on-cost-summary
       # keep this in sync with go.mod in go-sdk/
       - name: Setup Go
         uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e  # 
v7.0.0
@@ -1235,7 +1240,7 @@ jobs:
   tests-ts-sdk:
     name: "TypeScript SDK tests"
     needs: [build-info]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 8, 
32) }}
     timeout-minutes: 15
     permissions:
       contents: read
@@ -1245,6 +1250,8 @@ jobs:
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
         with:
           persist-credentials: false
+      - name: "RunsOn cost summary"
+        uses: ./.github/actions/runs-on-cost-summary
       - name: Setup pnpm
         uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413  # 
v6.1.0
         with:
@@ -1269,7 +1276,7 @@ jobs:
   tests-java-sdk:
     name: "Java SDK tests"
     needs: [build-info]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 32, 
128) }}
     timeout-minutes: 15
     permissions:
       contents: read
@@ -1285,6 +1292,8 @@ jobs:
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
         with:
           persist-credentials: false
+      - name: "RunsOn cost summary"
+        uses: ./.github/actions/runs-on-cost-summary
       - name: Setup Java
         uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad  # 
v6.1.0
         with:
@@ -1299,7 +1308,7 @@ jobs:
   build-java-sdk-docs:
     name: "Java SDK docs"
     needs: [build-info]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 8, 
32) }}
     timeout-minutes: 30
     permissions:
       contents: read
@@ -1331,7 +1340,7 @@ jobs:
   build-ts-sdk-docs:
     name: "TypeScript SDK docs"
     needs: [build-info]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 4, 
16) }}
     timeout-minutes: 30
     permissions:
       contents: read
@@ -1368,7 +1377,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 4, 16) }}
       platform: ${{ needs.build-info.outputs.platform }}
       default-python-version: "${{ 
needs.build-info.outputs.default-python-version }}"
       python-versions: ${{ needs.build-info.outputs.python-versions }}
@@ -1389,7 +1398,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       default-python-version: "${{ 
needs.build-info.outputs.default-python-version }}"
       use-uv: ${{ needs.build-info.outputs.use-uv }}
@@ -1669,7 +1678,7 @@ jobs:
       - tests-special
       - tests-with-lowest-direct-resolution-core
       - tests-with-lowest-direct-resolution-providers
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 8, 
32) }}
     if: needs.build-info.outputs.run-unit-tests == 'true'
     steps:
       - name: "Cleanup repo"
@@ -1679,6 +1688,8 @@ jobs:
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
         with:
           persist-credentials: false
+      - name: "RunsOn cost summary"
+        uses: ./.github/actions/runs-on-cost-summary
       - name: "Free up disk space"
         shell: bash
         run: ./scripts/tools/free_up_disk_space.sh
diff --git a/.github/workflows/ci-arm.yml b/.github/workflows/ci-arm.yml
index 180182647a6..1bde91c7aca 100644
--- a/.github/workflows/ci-arm.yml
+++ b/.github/workflows/ci-arm.yml
@@ -20,9 +20,10 @@
 # `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
-# print-platform job. Edit both files together when changing pipeline shape.
+# differing only in their trigger surface, the hardcoded `platform` value in
+# `build-info`'s outputs (and the `PLATFORM` selective checks pick the runner
+# type for), and the architecture-specific print-platform job. Edit both files
+# together when changing pipeline shape.
 name: Tests (ARM)
 on:  # yamllint disable-line rule:truthy
   schedule:
@@ -187,7 +188,7 @@ jobs:
       latest-versions-only: ${{ 
steps.selective-checks.outputs.latest-versions-only }}
       mysql-exclude: ${{ steps.selective-checks.outputs.mysql-exclude }}
       mysql-versions: ${{ steps.selective-checks.outputs.mysql-versions }}
-      # platform / runner-type are hardcoded per workflow file (this is the
+      # platform is hardcoded per workflow file (this is the
       # ARM copy). The matching AMD copy lives in ci-amd.yml.
       platform: "linux/arm64"
       postgres-exclude: ${{ steps.selective-checks.outputs.postgres-exclude }}
@@ -229,7 +230,7 @@ jobs:
       run-task-sdk-integration-tests: ${{ 
steps.selective-checks.outputs.run-task-sdk-integration-tests }}
       run-breeze-integration-tests: ${{ 
steps.selective-checks.outputs.run-breeze-integration-tests }}
       run-scripts-tests: ${{ steps.selective-checks.outputs.run-scripts-tests 
}}
-      runner-type: '["ubuntu-22.04-arm"]'
+      runner-type: ${{ steps.selective-checks.outputs.runner-type }}
       run-ui-tests: ${{ steps.selective-checks.outputs.run-ui-tests }}
       run-ui-e2e-tests: ${{ steps.selective-checks.outputs.run-ui-e2e-tests }}
       run-unit-tests: ${{ steps.selective-checks.outputs.run-unit-tests }}
@@ -311,6 +312,8 @@ jobs:
           COMMIT_REF: "${{ github.sha }}"
           VERBOSE: "false"
           GITHUB_CONTEXT_INPUT: "${{ runner.temp }}/github_context.json"
+          # Repository variable: "true" moves every run, pushes included, back 
to public runners.
+          USE_PUBLIC_RUNNERS: "${{ vars.USE_PUBLIC_RUNNERS }}"
           PLATFORM: "linux/arm64"
           SELECTIVE_TESTS_ON_PUSH: "true"
           PUSH_BASE_COMMIT: ${{ 
needs.check-run-needed.outputs.push-base-commit }}
@@ -448,7 +451,7 @@ jobs:
     timeout-minutes: 45
     name: "MyPy providers checks"
     needs: [build-info, build-ci-images]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 4, 
16) }}
     if: needs.build-info.outputs.run-mypy-providers == 'true'
     env:
       PYTHON_MAJOR_MINOR_VERSION: "${{ 
needs.build-info.outputs.default-python-version }}"
@@ -489,7 +492,7 @@ jobs:
     timeout-minutes: 20
     name: "Migration round-trip check"
     needs: [build-info, build-ci-images]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 8, 
32) }}
     if: needs.build-info.outputs.has-migrations == 'true'
     env:
       PYTHON_MAJOR_MINOR_VERSION: "${{ 
needs.build-info.outputs.default-python-version }}"
@@ -611,7 +614,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "postgres"
       test-name: "Postgres"
@@ -640,7 +643,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "postgres"
       test-name: "Postgres"
@@ -669,7 +672,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "mysql"
       test-name: "MySQL"
@@ -699,7 +702,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "mysql"
       test-name: "MySQL"
@@ -728,7 +731,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "sqlite"
       test-name: "Sqlite"
@@ -759,7 +762,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "sqlite"
       test-name: "Sqlite"
@@ -790,7 +793,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 32, 128) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "sqlite"
       test-name: ""
@@ -820,7 +823,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 32, 128) }}
       platform: ${{ needs.build-info.outputs.platform }}
       backend: "sqlite"
       test-name: ""
@@ -906,7 +909,7 @@ jobs:
     if: >
       needs.build-info.outputs.run-unit-tests == 'true'
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 32, 128) }}
       platform: ${{ needs.build-info.outputs.platform }}
       test-name: "LowestDeps"
       force-lowest-dependencies: "true"
@@ -936,7 +939,7 @@ jobs:
       packages: read
     if: needs.build-info.outputs.run-unit-tests == 'true'
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 32, 128) }}
       platform: ${{ needs.build-info.outputs.platform }}
       test-name: "LowestDeps"
       force-lowest-dependencies: "true"
@@ -1047,7 +1050,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 32, 128) }}
       platform: ${{ needs.build-info.outputs.platform }}
       default-python-version: "${{ 
needs.build-info.outputs.default-python-version }}"
       python-versions: ${{ needs.build-info.outputs.python-versions }}
@@ -1063,7 +1066,7 @@ jobs:
   tests-go-sdk:
     name: "Go SDK tests"
     needs: [build-info]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 8, 
32) }}
     timeout-minutes: 15
     permissions:
       contents: read
@@ -1079,6 +1082,8 @@ jobs:
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
         with:
           persist-credentials: false
+      - name: "RunsOn cost summary"
+        uses: ./.github/actions/runs-on-cost-summary
       # keep this in sync with go.mod in go-sdk/
       - name: Setup Go
         uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e  # 
v7.0.0
@@ -1117,7 +1122,7 @@ jobs:
   tests-ts-sdk:
     name: "TypeScript SDK tests"
     needs: [build-info]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 8, 
32) }}
     timeout-minutes: 15
     permissions:
       contents: read
@@ -1127,6 +1132,8 @@ jobs:
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
         with:
           persist-credentials: false
+      - name: "RunsOn cost summary"
+        uses: ./.github/actions/runs-on-cost-summary
       - name: Setup pnpm
         uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413  # 
v6.1.0
         with:
@@ -1151,7 +1158,7 @@ jobs:
   tests-java-sdk:
     name: "Java SDK tests"
     needs: [build-info]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 32, 
128) }}
     timeout-minutes: 15
     permissions:
       contents: read
@@ -1167,6 +1174,8 @@ jobs:
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
         with:
           persist-credentials: false
+      - name: "RunsOn cost summary"
+        uses: ./.github/actions/runs-on-cost-summary
       - name: Setup Java
         uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad  # 
v6.1.0
         with:
@@ -1181,7 +1190,7 @@ jobs:
   build-java-sdk-docs:
     name: "Java SDK docs"
     needs: [build-info]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 8, 
32) }}
     timeout-minutes: 30
     permissions:
       contents: read
@@ -1213,7 +1222,7 @@ jobs:
   build-ts-sdk-docs:
     name: "TypeScript SDK docs"
     needs: [build-info]
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 4, 
16) }}
     timeout-minutes: 30
     permissions:
       contents: read
@@ -1250,7 +1259,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 4, 16) }}
       platform: ${{ needs.build-info.outputs.platform }}
       default-python-version: "${{ 
needs.build-info.outputs.default-python-version }}"
       python-versions: ${{ needs.build-info.outputs.python-versions }}
@@ -1271,7 +1280,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ needs.build-info.outputs.runner-type }}
+      runners: ${{ format(needs.build-info.outputs.runner-type, 8, 32) }}
       platform: ${{ needs.build-info.outputs.platform }}
       default-python-version: "${{ 
needs.build-info.outputs.default-python-version }}"
       use-uv: ${{ needs.build-info.outputs.use-uv }}
@@ -1550,7 +1559,7 @@ jobs:
       - tests-special
       - tests-with-lowest-direct-resolution-core
       - tests-with-lowest-direct-resolution-providers
-    runs-on: ${{ fromJSON(needs.build-info.outputs.runner-type) }}
+    runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 8, 
32) }}
     if: needs.build-info.outputs.run-unit-tests == 'true'
     steps:
       - name: "Cleanup repo"
@@ -1560,6 +1569,8 @@ jobs:
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
         with:
           persist-credentials: false
+      - name: "RunsOn cost summary"
+        uses: ./.github/actions/runs-on-cost-summary
       - name: "Free up disk space"
         shell: bash
         run: ./scripts/tools/free_up_disk_space.sh
diff --git a/.github/workflows/ci-image-build.yml 
b/.github/workflows/ci-image-build.yml
index 315256420fd..23f8e1f724c 100644
--- a/.github/workflows/ci-image-build.yml
+++ b/.github/workflows/ci-image-build.yml
@@ -128,7 +128,7 @@ jobs:
         python-version: ${{ fromJSON(inputs.python-versions) }}
     timeout-minutes: 110
     name: "Build CI ${{ inputs.platform }} image ${{ matrix.python-version }}"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     env:
       BACKEND: sqlite
       PYTHON_MAJOR_MINOR_VERSION: ${{ matrix.python-version }}
@@ -147,24 +147,38 @@ jobs:
         with:
           ref: ${{ inputs.checkout-ref }}
           persist-credentials: false
+      # A local action is read from the workspace, and `checkout-ref` may be a 
release ref cut
+      # before the stash actions existed, so they are loaded from the 
workflow's own version. The
+      # runner-preparing scripts too: an older `make_mnt_writeable.sh` empties 
/mnt, which on
+      # RunsOn holds the runner's own disks.
+      - name: "Checkout the workflow version for its actions and runner 
scripts"
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
+        with:
+          persist-credentials: false
+          path: workflow-version
+          sparse-checkout: |
+            .github/actions
+            scripts/ci
       - name: "Free up disk space"
         shell: bash
         run: ./scripts/tools/free_up_disk_space.sh
       - name: "Make /mnt writeable"
-        run: ./scripts/ci/make_mnt_writeable.sh
+        run: ./workflow-version/scripts/ci/make_mnt_writeable.sh
       - name: "Move docker to /mnt"
-        run: ./scripts/ci/move_docker_to_mnt.sh
+        run: ./workflow-version/scripts/ci/move_docker_to_mnt.sh
       - name: "Install Breeze"
         uses: ./.github/actions/breeze
       # A ref is not a commit: a branch documented twice moves between the two 
publishes, and
       # an image built before it moved is a cache, not an answer. The commit 
each stashed image
       # is saved with tells the two apart, and it is stashed separately so 
that deciding costs
       # a few bytes rather than the multi-gigabyte image the decision may make 
unnecessary.
+      # `only-current-branch` must stay true on the image restores below: runs 
on RunsOn (canaries
+      # and release-branch pushes) save these image stashes as S3 digests 
only, so a fallback to the
+      # base branch would find nothing on public runners anyway.
       - name: >
           Restore the commit the CI image stashed for ref ${{ 
inputs.image-stash-ref }}
           was built from
-        # yamllint disable-line rule:line-length
-        uses: 
apache/infrastructure-actions/stash/restore@c968777141a2afc98d1a09c9f6979455bfebaae3
  # restore/v1.0.1
+        uses: ./workflow-version/.github/actions/stash-restore
         with:
           key: "ci-image-commit-v3-${{ inputs.platform }}-${{ 
env.PYTHON_MAJOR_MINOR_VERSION }}\
             -${{ inputs.image-stash-ref }}"
@@ -197,8 +211,7 @@ jobs:
       - name: >
           Restore CI docker image built for ref ${{ inputs.image-stash-ref }}
           ${{ inputs.platform }}:${{ env.PYTHON_MAJOR_MINOR_VERSION }}
-        # yamllint disable-line rule:line-length
-        uses: 
apache/infrastructure-actions/stash/restore@c968777141a2afc98d1a09c9f6979455bfebaae3
  # restore/v1.0.1
+        uses: ./workflow-version/.github/actions/stash-restore
         with:
           key: "ci-image-save-v3-${{ inputs.platform }}-${{ 
env.PYTHON_MAJOR_MINOR_VERSION }}\
             -${{ inputs.image-stash-ref }}"
@@ -244,8 +257,7 @@ jobs:
           --cache-file 
/tmp/ci-cache-mount-save-v4-${PYTHON_MAJOR_MINOR_VERSION}.tar.gz
         if: steps.restore-cache-mount.outputs.stash-hit == 'true'
       - name: "Restore CI docker image ${{ inputs.platform }}:${{ 
env.PYTHON_MAJOR_MINOR_VERSION }}"
-        # yamllint disable-line rule:line-length
-        uses: 
apache/infrastructure-actions/stash/restore@c968777141a2afc98d1a09c9f6979455bfebaae3
  # restore/v1.0.1
+        uses: ./workflow-version/.github/actions/stash-restore
         with:
           key: "ci-image-save-v3-${{ inputs.platform }}-${{ 
env.PYTHON_MAJOR_MINOR_VERSION }}"
           path: "/mnt/"
@@ -336,7 +348,7 @@ jobs:
         shell: bash
         if: inputs.upload-image-artifact == 'true' && 
steps.stashed-image.outputs.reusable != 'true'
       - name: "Stash CI docker image ${{ env.PYTHON_MAJOR_MINOR_VERSION }}"
-        uses: 
apache/infrastructure-actions/stash/save@7d3afde57801d8c9a2db9730c28ae4e036cc2ab9
  # save/v1.0.2
+        uses: ./workflow-version/.github/actions/stash-save
         with:
           key: ci-image-save-v3-${{ inputs.platform }}-${{ 
env.PYTHON_MAJOR_MINOR_VERSION }}
           path: "/mnt/ci-image-save-*-${{ env.PYTHON_MAJOR_MINOR_VERSION 
}}.tar"
@@ -350,7 +362,7 @@ jobs:
       # what a later publish of that same ref reads - hence a retention that 
outlives the shared
       # one, the same ref being rebuilt three days apart at the very least.
       - name: "Stash CI docker image built for ref ${{ inputs.image-stash-ref 
}}"
-        uses: 
apache/infrastructure-actions/stash/save@7d3afde57801d8c9a2db9730c28ae4e036cc2ab9
  # save/v1.0.2
+        uses: ./workflow-version/.github/actions/stash-save
         with:
           key: "ci-image-save-v3-${{ inputs.platform }}-${{ 
env.PYTHON_MAJOR_MINOR_VERSION }}\
             -${{ inputs.image-stash-ref }}"
@@ -364,7 +376,7 @@ jobs:
       # Saved last and with the image's retention, so that finding this commit 
is enough to
       # know the image it describes is there to be restored.
       - name: "Stash the commit the CI image for ref ${{ 
inputs.image-stash-ref }} was built from"
-        uses: 
apache/infrastructure-actions/stash/save@7d3afde57801d8c9a2db9730c28ae4e036cc2ab9
  # save/v1.0.2
+        uses: ./workflow-version/.github/actions/stash-save
         with:
           key: "ci-image-commit-v3-${{ inputs.platform }}-${{ 
env.PYTHON_MAJOR_MINOR_VERSION }}\
             -${{ inputs.image-stash-ref }}"
diff --git a/.github/workflows/ci-image-checks.yml 
b/.github/workflows/ci-image-checks.yml
index affe6ebdb8d..1a1c06c6804 100644
--- a/.github/workflows/ci-image-checks.yml
+++ b/.github/workflows/ci-image-checks.yml
@@ -119,7 +119,7 @@ jobs:
   static-checks:
     timeout-minutes: 45
     name: "Static checks"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 32, 128) }}
     env:
       PYTHON_MAJOR_MINOR_VERSION: "${{ inputs.default-python-version }}"
       UPGRADE_TO_NEWER_DEPENDENCIES: "${{ inputs.upgrade-to-newer-dependencies 
}}"
@@ -175,7 +175,7 @@ jobs:
   build-docs:
     timeout-minutes: 150
     name: "Build documentation"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 32, 128) }}
     if: inputs.docs-build == 'true'
     env:
       GITHUB_REPOSITORY: ${{ github.repository }}
@@ -334,7 +334,7 @@ jobs:
       id-token: write
       contents: read
     needs: build-docs
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     env:
       GITHUB_REPOSITORY: ${{ github.repository }}
       GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -466,7 +466,7 @@ jobs:
   test-python-api-client:
     timeout-minutes: 60
     name: "Test Python API client"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 4, 16) }}
     if: inputs.run-api-codegen == 'true'
     env:
       BACKEND: "postgres"
diff --git a/.github/workflows/finalize-tests.yml 
b/.github/workflows/finalize-tests.yml
index 043aa8f0ebf..b664e49cf06 100644
--- a/.github/workflows/finalize-tests.yml
+++ b/.github/workflows/finalize-tests.yml
@@ -80,7 +80,7 @@ permissions:
   contents: read
 jobs:
   update-constraints:
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     timeout-minutes: 80
     name: "Update constraints"
     permissions:
@@ -103,6 +103,8 @@ jobs:
         with:
           # Needed to perform push action
           persist-credentials: false
+      - name: "RunsOn cost summary"
+        uses: ./.github/actions/runs-on-cost-summary
       - name: "Set constraints branch name"
         id: constraints-branch
         run: ./scripts/ci/constraints/ci_branch_constraints.sh >> 
${GITHUB_OUTPUT}
@@ -124,7 +126,7 @@ jobs:
   # Constraint commits are now handled by the dedicated 
update-constraints-on-push workflow
   # triggered when uv.lock changes are pushed to main/test branches.
   dependency-upgrade-summary:
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 4, 16) }}
     needs: [update-constraints]
     if: inputs.upgrade-to-newer-dependencies == 'true' && inputs.platform == 
'linux/amd64'
     name: "Deps ${{ matrix.python-version }}:${{ matrix.constraints-mode }}"
diff --git a/.github/workflows/generate-constraints.yml 
b/.github/workflows/generate-constraints.yml
index 9846e44ce71..58312ea63c4 100644
--- a/.github/workflows/generate-constraints.yml
+++ b/.github/workflows/generate-constraints.yml
@@ -85,7 +85,7 @@ jobs:
       contents: read
     timeout-minutes: 70
     name: Generate constraints for ${{ matrix.python-version }} on ${{ 
inputs.platform }}
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     strategy:
       fail-fast: false
       max-parallel: 20
@@ -114,6 +114,8 @@ jobs:
           platform: ${{ inputs.platform }}
           python: ${{ matrix.python-version }}
           use-uv: ${{ inputs.use-uv }}
+          # Runs the checkout-ref's own make_mnt_writeable.sh, which on old 
refs empties /mnt and
+          # would kill a RunsOn runner: every caller passing checkout-ref pins 
public runners.
           make-mnt-writeable-and-cleanup: true
       - name: "Install prek"
         uses: ./.github/actions/install-prek
diff --git a/.github/workflows/helm-tests.yml b/.github/workflows/helm-tests.yml
index 7e779afcc2a..2cbe0603a43 100644
--- a/.github/workflows/helm-tests.yml
+++ b/.github/workflows/helm-tests.yml
@@ -52,7 +52,7 @@ jobs:
     name: >-
       Unit tests Helm: ${{ matrix.helm-test-package }}
       (K8S ${{ matrix.kubernetes-version }})
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 32, 128) }}
     strategy:
       fail-fast: false
       max-parallel: 20
@@ -98,7 +98,7 @@ jobs:
   tests-helm-release:
     timeout-minutes: 80
     name: "Release Helm"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     env:
       PYTHON_MAJOR_MINOR_VERSION: "${{inputs.default-python-version}}"
       GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/integration-system-tests.yml 
b/.github/workflows/integration-system-tests.yml
index 202d70cd9ef..efc8d578b5c 100644
--- a/.github/workflows/integration-system-tests.yml
+++ b/.github/workflows/integration-system-tests.yml
@@ -75,7 +75,7 @@ jobs:
     timeout-minutes: 30
     if: inputs.testable-core-integrations != '[]'
     name: "Integration core ${{ matrix.integration }}"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 32, 128) }}
     strategy:
       fail-fast: false
       max-parallel: 20
@@ -126,7 +126,7 @@ jobs:
     timeout-minutes: 30
     if: inputs.testable-providers-integrations != '[]' && 
inputs.skip-providers-tests != 'true'
     name: "Integration: providers ${{ matrix.integration }}"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     strategy:
       fail-fast: false
       max-parallel: 20
@@ -176,7 +176,7 @@ jobs:
     timeout-minutes: 30
     if: inputs.run-system-tests == 'true'
     name: "System Tests"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 4, 16) }}
     env:
       BACKEND: "postgres"
       BACKEND_VERSION: ${{ inputs.default-postgres-version }}
diff --git a/.github/workflows/k8s-tests.yml b/.github/workflows/k8s-tests.yml
index d295b9d713d..39b0277e110 100644
--- a/.github/workflows/k8s-tests.yml
+++ b/.github/workflows/k8s-tests.yml
@@ -62,7 +62,7 @@ jobs:
   tests-kubernetes:
     timeout-minutes: 60
     name: "K8S System:${{ matrix.executor }}-${{ matrix.kubernetes-combo 
}}-${{ matrix.use-standard-naming }}"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     strategy:
       fail-fast: false
       max-parallel: 20
@@ -149,7 +149,7 @@ jobs:
   tests-kubernetes-lang-sdk:
     timeout-minutes: 60
     name: "K8S Lang-SDK:${{ inputs.lang-sdk-kubernetes-combo }}"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     env:
       DEBUG_RESOURCES: ${{ inputs.debug-resources }}
       INCLUDE_SUCCESS_OUTPUTS: ${{ inputs.include-success-outputs }}
diff --git a/.github/workflows/kustomize-overlays-tests.yml 
b/.github/workflows/kustomize-overlays-tests.yml
index 1cca2b85000..5fef9c1ac8a 100644
--- a/.github/workflows/kustomize-overlays-tests.yml
+++ b/.github/workflows/kustomize-overlays-tests.yml
@@ -53,7 +53,7 @@ jobs:
   smoke-test-overlay:
     timeout-minutes: 40
     name: "Overlay smoke test: ${{ matrix.overlay }}"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 4, 16) }}
     strategy:
       fail-fast: false
       max-parallel: 4
diff --git a/.github/workflows/prod-image-build.yml 
b/.github/workflows/prod-image-build.yml
index f136cd1ce61..31eaabace18 100644
--- a/.github/workflows/prod-image-build.yml
+++ b/.github/workflows/prod-image-build.yml
@@ -119,7 +119,7 @@ jobs:
   build-prod-packages:
     name: "Build Airflow and provider distributions"
     timeout-minutes: 10
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     if: inputs.prod-image-build == 'true'
     env:
       PYTHON_MAJOR_MINOR_VERSION: "${{ inputs.default-python-version }}"
@@ -199,7 +199,7 @@ jobs:
         python-version: ${{ fromJSON(inputs.python-versions) }}
     timeout-minutes: 80
     name: "Build PROD ${{ inputs.build-type }} image ${{ matrix.python-version 
}}"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     needs:
       - build-prod-packages
     env:
@@ -289,7 +289,7 @@ jobs:
           breeze prod-image save --platform "${PLATFORM}" --image-file-dir 
"/mnt"
         if: inputs.upload-image-artifact == 'true'
       - name: "Stash PROD docker image ${{ env.PYTHON_MAJOR_MINOR_VERSION }}"
-        uses: 
apache/infrastructure-actions/stash/save@7d3afde57801d8c9a2db9730c28ae4e036cc2ab9
  # save/v1.0.2
+        uses: ./.github/actions/stash-save
         with:
           key: prod-image-save-v3-${{ inputs.platform }}-${{ 
env.PYTHON_MAJOR_MINOR_VERSION }}
           path: "/mnt/prod-image-save-*-${{ env.PYTHON_MAJOR_MINOR_VERSION 
}}.tar"
diff --git a/.github/workflows/push-image-cache.yml 
b/.github/workflows/push-image-cache.yml
index 4d04deb09da..3dc4693e0df 100644
--- a/.github/workflows/push-image-cache.yml
+++ b/.github/workflows/push-image-cache.yml
@@ -79,7 +79,7 @@ on:  # yamllint disable-line rule:truthy
 jobs:
   push-ci-image-cache:
     name: "Push CI ${{ inputs.cache-type }}:${{ matrix.python }} image cache "
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     permissions:
       contents: read
       packages: write
@@ -149,7 +149,7 @@ jobs:
 
   push-prod-image-cache:
     name: "Push PROD ${{ inputs.cache-type }}:${{ matrix.python }} image cache"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     permissions:
       contents: read
       packages: write
diff --git a/.github/workflows/special-tests.yml 
b/.github/workflows/special-tests.yml
index 6ac4d60d1ba..ff56fbb9df5 100644
--- a/.github/workflows/special-tests.yml
+++ b/.github/workflows/special-tests.yml
@@ -94,7 +94,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 8, 32) }}
       platform: ${{ inputs.platform }}
       downgrade-sqlalchemy: "true"
       test-name: "MinSQLAlchemy-Postgres"
@@ -119,7 +119,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 8, 32) }}
       platform: ${{ inputs.platform }}
       downgrade-sqlalchemy: "true"
       test-name: "MinSQLAlchemy-Postgres"
@@ -144,7 +144,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 8, 32) }}
       platform: ${{ inputs.platform }}
       upgrade-sqlalchemy: "true"
       test-name: "LatestSQLAlchemy-Postgres"
@@ -169,7 +169,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 8, 32) }}
       platform: ${{ inputs.platform }}
       upgrade-sqlalchemy: "true"
       test-name: "LatestSQLAlchemy-Postgres"
@@ -194,7 +194,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 32, 128) }}
       platform: ${{ inputs.platform }}
       upgrade-boto: "true"
       test-name: "LatestBoto-Postgres"
@@ -220,7 +220,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 8, 32) }}
       platform: ${{ inputs.platform }}
       upgrade-boto: "true"
       test-name: "LatestBoto-Postgres"
@@ -246,7 +246,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 8, 32) }}
       platform: ${{ inputs.platform }}
       downgrade-pendulum: "true"
       test-name: "Pendulum2-Postgres"
@@ -272,7 +272,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 8, 32) }}
       platform: ${{ inputs.platform }}
       downgrade-pendulum: "true"
       test-name: "Pendulum2-Postgres"
@@ -298,7 +298,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 4, 16) }}
       platform: ${{ inputs.platform }}
       test-name: "Postgres"
       test-scope: "Quarantined"
@@ -323,7 +323,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 4, 16) }}
       platform: ${{ inputs.platform }}
       test-name: "Postgres"
       test-scope: "Quarantined"
@@ -348,7 +348,7 @@ jobs:
       contents: read
       packages: read
     with:
-      runners: ${{ inputs.runners }}
+      runners: ${{ format(inputs.runners, 32, 128) }}
       platform: ${{ inputs.platform }}
       test-name: "SystemTest"
       test-scope: "System"
diff --git a/.github/workflows/test-providers.yml 
b/.github/workflows/test-providers.yml
index e7b3575b3f1..406ebde52fa 100644
--- a/.github/workflows/test-providers.yml
+++ b/.github/workflows/test-providers.yml
@@ -72,7 +72,7 @@ jobs:
   prepare-install-verify-provider-distributions:
     timeout-minutes: 80
     name: "Providers ${{ matrix.package-format }} tests"
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     strategy:
       fail-fast: false
       matrix:
@@ -178,7 +178,7 @@ jobs:
     timeout-minutes: 80
     # yamllint disable rule:line-length
     name: Compat ${{ matrix.compat.airflow-version }}:P${{ 
matrix.compat.python-version }}:${{ matrix.test-types.description }}
-    runs-on: ${{ fromJSON(inputs.runners) }}
+    runs-on: ${{ format(fromJSON(inputs.runners)[0], 8, 32) }}
     strategy:
       fail-fast: false
       matrix:
diff --git a/.github/workflows/ui-e2e-tests.yml 
b/.github/workflows/ui-e2e-tests.yml
index 8b8ad21e4bf..1ee5e1e6a67 100644
--- a/.github/workflows/ui-e2e-tests.yml
+++ b/.github/workflows/ui-e2e-tests.yml
@@ -88,7 +88,7 @@ jobs:
   test-ui-e2e-tests:
     timeout-minutes: 90
     name: ${{ inputs.workflow-name || 'UI E2E Tests' }}
-    runs-on: ${{ fromJSON(inputs.runners || '["ubuntu-24.04"]') }}
+    runs-on: ${{ format(fromJSON(inputs.runners || '["ubuntu-24.04"]')[0], 8, 
32) }}
     env:
       PYTHON_MAJOR_MINOR_VERSION: "${{ inputs.default-python-version || '3.11' 
}}"
       GITHUB_REPOSITORY: ${{ github.repository }}
diff --git a/README.md b/README.md
index 8e7f8f70b41..e1a994222e9 100644
--- a/README.md
+++ b/README.md
@@ -603,3 +603,5 @@ The CI infrastructure for Apache Airflow has been sponsored 
by:
 
 <a href="https://astronomer.io";><img 
src="https://assets2.astronomer.io/logos/logoForLIGHTbackground.png"; 
alt="astronomer.io" width="250px"></a>
 <a href="https://aws.amazon.com/opensource/";><img 
src="https://github.com/apache/airflow/blob/main/providers/amazon/docs/integration-logos/[email protected]?raw=true";
 alt="AWS OpenSource" width="130px"></a>
+
+The self-hosted CI runners on AWS are managed by 
[RunsOn](https://runs-on.com), which is free for open-source projects.
diff --git a/dev/breeze/doc/ci/04_selective_checks.md 
b/dev/breeze/doc/ci/04_selective_checks.md
index 2356d61c3ea..d15ff81dd5e 100644
--- a/dev/breeze/doc/ci/04_selective_checks.md
+++ b/dev/breeze/doc/ci/04_selective_checks.md
@@ -33,7 +33,7 @@
   - [Skipping prek hooks (Static checks)](#skipping-prek-hooks-static-checks)
   - [Suspended providers](#suspended-providers)
   - [Selective check outputs](#selective-check-outputs)
-  - [Committer vs. Non-committer PRs](#committer-vs-non-committer-prs)
+  - [Self-hosted (RunsOn) vs. public 
runners](#self-hosted-runson-vs-public-runners)
   - [Changing behaviours of the CI runs by setting 
labels](#changing-behaviours-of-the-ci-runs-by-setting-labels)
 
 <!-- END doctoc generated TOC please keep comment here to allow auto update -->
@@ -689,6 +689,7 @@ GitHub Actions to pass the list of parameters to a command 
to execute
 | run-www-tests                                           | Whether Legacy WWW 
tests should be run ("true"/"false")                                            
     | true                                     |      |
 | amd-runners                                             | List of labels 
assigned for runners for that build for public AMD runners                      
         | \["ubuntu-22.04"\]                       |      |
 | arm-runners                                             | List of labels 
assigned for runners for that build for public ARM runners                      
         | \["ubuntu-22.04-arm"\]                   |      |
+| runner-type                                             | Runner labels for 
the CI jobs on this platform: public, or a RunsOn template jobs fill with 
cpu/ram     | \["ubuntu-22.04"\]                       |      |
 | selected-providers-list-as-string                       | List of providers 
affected when they are selectively affected.                                    
      | airbyte http                             | *    |
 | skip-prek-hooks                                         | Which prek hooks 
should be skipped during the static-checks run                                  
       | flynt,identity                           |      |
 | skip-providers-tests                                    | When provider 
tests should be skipped (on non-main branch or when no provider changes 
detected)         | true                                     |      |
@@ -698,27 +699,82 @@ GitHub Actions to pass the list of parameters to a 
command to execute
 | upgrade-to-newer-dependencies                           | Whether the image 
build should attempt to upgrade all dependencies (true/false or commit hash)    
      | false                                    |      |
 
 
-## Committer vs. Non-committer PRs
-
-There is a difference in how the CI jobs are run for committer and 
non-committer PRs from forks.
-The main reason is security; we do not want to run untrusted code on our 
infrastructure for self-hosted runners.
-
-Currently there is no difference because we are not using `self-hosted` 
runners (until we implement `Action
-Runner Controller` but most of the jobs, committer builds will use 
"Self-hosted" runners by default,
-while non-committer builds will use "Public" runners. For committers, this can 
be overridden by setting the
-`use public runners` label in the PR.
+## Self-hosted (RunsOn) vs. public runners
+
+The `Tests (AMD)` / `Tests (ARM)` jobs run either on GitHub-hosted ("public") 
runners or on self-hosted
+[RunsOn](https://runs-on.com) EC2 runners in the Airflow AWS account. 
Selective checks route runs of
+code from `apache/airflow` itself there; this is a routing preference, not 
what keeps fork code off
+RunsOn (see below). The `runner-type` output is decided as follows, first 
match wins:
+
+1. Runs in any repository other than `apache/airflow` (forks running their own 
CI) use public runners.
+2. Any run uses public runners while the repository variable 
`USE_PUBLIC_RUNNERS` is `true`: the
+   switch for moving `main` off RunsOn without a PR, e.g. when the stack is 
unavailable.
+3. PRs with the `use public runners` label use public runners.
+4. Any `push`, `schedule` or `workflow_dispatch` in `apache/airflow` (canary 
runs and the release
+   branch pushes, all triggered by people with write access) uses RunsOn.
+5. Other events than `pull_request` use public runners.
+6. PRs with the `use self-hosted runners` label use RunsOn, if a committer's 
(`COMMITTERS` in
+   `global_constants.py`) latest review of the PR's head commit is an approval 
and no committer's
+   request for changes stands. The label outlives the commit it was added for, 
so on its own it would
+   vouch for every later push: a new push sends the PR back to public runners 
until the head commit is
+   approved again, and so does a dismissed approval if no other committer's 
approval of the head commit
+   remains. A committer's request for changes keeps the PR on public runners 
across pushes, as it blocks
+   a merge on GitHub, until that committer reviews again or the review is 
dismissed. The label must
+   also still be on the PR: a re-run reuses the labels of its original event, 
so they are checked
+   against the PR's current ones. If the PR or its reviews cannot be read, the 
label is ignored as well.
+7. PRs with the `non committer build` label use public runners.
+8. PRs opened by a bot (Dependabot and the like), or from a `dependabot/` 
branch, use public runners:
+   they carry code nobody has reviewed yet.
+9. PRs from a branch of `apache/airflow` use RunsOn; PRs from forks use public 
runners.
+
+Labels are read from the event that started the run, and the CI workflows do 
not run on `labeled`. After
+adding or removing a label, push a commit or close and reopen the PR; 
re-running an earlier run reuses
+its original labels, and "Re-run failed jobs" also reuses that run's runner 
decision. A push voids the
+approval `use self-hosted runners` needs, so for that label approve the head 
commit and then close and
+reopen the PR.
+
+The RunsOn value is a label template such as
+`["runs-on=<run_id>/family=.../image=ubuntu22-full-x64/volume=150gb/cpu={0}/ram={1}"]`.
 Every job
+picks its size class with `format(fromJSON(inputs.runners)[0], <cpu>, <ram>)` 
(4/16, 8/32 or 32/128),
+or, for a single-job reusable workflow, its caller passes `format(<runners>, 
<cpu>, <ram>)`;
+on a public label the same call returns the label unchanged. Steps that only 
make sense on RunsOn are
+gated on `runner.environment == 'self-hosted'`:
+
+* The EC2 cost summary of each job.
+* The image stashes (`.github/actions/stash-restore` / `stash-save`). On 
RunsOn the image tarballs go to
+  the stack's S3 cache bucket, named by their sha256, and only the digest is 
stashed as a GitHub artifact.
+  Any RunsOn job can write to the bucket, so a tarball is used only if it 
matches the digest stashed by
+  the same run, branch and repository (or, for a PR, its base branch). The 
smaller caches (mount cache, prek, docs inventory) use
+  the ASF stash action on both kinds of runners, so PRs on public runners keep 
the ones canary runs save;
+  the price is that every RunsOn canary uploads the mount cache from AWS to 
GitHub.
+
+The decision above is not a security boundary. Under the `pull_request` event 
the code that
+evaluates it is the PR's own: a fork PR runs its own workflow files and breeze 
sources, and can name
+RunsOn labels in `runs-on:` directly. RunsOn launches a runner for any job of 
a repository its GitHub
+App is installed on, so nothing in this repository can prevent that. Keeping 
untrusted code away from
+anything that matters has to be enforced outside the repository, by:
+
+* The repository's Actions setting "Approval for running fork pull request 
workflows from
+  contributors", which must be "Require approval for all external 
contributors". That still exempts
+  members of the `apache` GitHub organization (ASF committers of any project 
who linked their GitHub
+  account) and repository collaborators, but every exempt person is 
identifiable.
+* The RunsOn stack, which must be dedicated to this repository and give the 
runners an instance role
+  that reaches nothing beyond the stack's own cache bucket (whose contents are 
never trusted, see the
+  stash actions), no EBS permissions, no SSH, network isolation between 
runners and a maximum run
+  time. RunsOn has no cap on instance sizes or counts and its budget only 
alerts, so the AWS account's
+  EC2 vCPU quotas are the cost ceiling.
 
 Pushes in a contributor fork are never committer builds, whoever pushes, 
because forks have no
 self-hosted runners.
 
 ## Changing behaviours of the CI runs by setting labels
 
-Also, currently for most of the jobs, committer builds by default use 
"Self-hosted" runners, while
-non-committer builds use "Public" runners. For committers, this can be 
overridden by setting the
-`use public runners` label in the PR. In the future when we might also switch 
committers to public runners.
-Committers will be able to use `use self-hosted runners` label in the PR to 
force using self-hosted runners.
-The `use public runners` label will still be available for committers and they 
will be able to set it for
-builds that also have `canary` label set to also switch the `canary` builds to 
public runners.
+PRs from `apache/airflow` branches run on self-hosted RunsOn runners and PRs 
from forks run on public
+runners (see above). Set `use public runners` to move a PR to public runners; 
a committer can set
+`use self-hosted runners` and approve the PR's head commit to move a fork PR 
to RunsOn, provided no
+committer's request for changes stands, then close and reopen the PR (a push 
would need a fresh
+approval; a committer should do the reopen, since an external author's reopen 
still needs a maintainer
+to approve the workflow run).
 
 If you are testing CI workflow changes and want to test it for more complete 
matrix combinations generated by
 the jobs - you can set `all versions` label in the PR. This will run the PRs 
with the same combinations
@@ -726,7 +782,7 @@ of versions as the `canary` main build. Using `all 
versions` is automatically se
 change in `pyproject.toml`.
 
 If you are running an `apache` PR, you can also set `canary` label for such PR 
and in this case, all the
-`canary` properties of build will be used: `self-hosted` runners, `full tests 
needed` mode, `all versions`
+`canary` properties of build will be used: `full tests needed` mode, `all 
versions`
 as well as all canary-specific jobs will run there. You can modify this 
behaviour of the `canary` run by
 applying `use public runners`, and `default versions only` labels to the PR as 
well which will still run
 a `canary` equivalent build but with public runners an default Python/K8S 
versions only - respectively.
diff --git a/dev/breeze/doc/ci/06_debugging.md 
b/dev/breeze/doc/ci/06_debugging.md
index c87c2b3f5b0..dfe9b968931 100644
--- a/dev/breeze/doc/ci/06_debugging.md
+++ b/dev/breeze/doc/ci/06_debugging.md
@@ -44,12 +44,16 @@ in some cases, you need to run the PR as coming from the 
"apache"
 repository rather than from your fork.
 
 You can also apply the label later and rebase the PR or close/reopen
-the PR to apply the label to the PR.
+the PR to apply the label to the PR. For `use self-hosted runners`, a
+committer approves the head commit (with no committer's request for
+changes standing) and closes/reopens the PR: a rebase needs a fresh
+approval.
 
 | Action to perform                                                            
                                                                                
    | Label to set          | PR from "apache" repo |
 
|------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------|:---------------------:|
 | Run the build with all combinations of all<br>python, backends, kubernetes 
etc on PR, <br>and run all types of tests for all test<br>groups.               
      | full tests needed     |                       |
 | Force to use public runners for the build                                    
                                                                                
    | use public runners    |                       |
+| Run a PR from a fork on the self-hosted RunsOn<br>runners (needs a 
committer's approval of its head commit<br>and no committer's request for 
changes)            | use self-hosted runners |                     |
 | Debug resources used during the build for <br>parallel jobs                  
                                                                                
    | debug ci resources    |                       |
 | Force running PR on latest versions of<br>python, backends, kubernetes etc. 
when you<br>want to save resources and test only latest<br>versions             
     | latest versions only  |                       |
 | Force running PR on minimal (default) <br>versions of python, backends, 
kubernetes etc.<br>in order to save resources and run tests only<br>for minimum 
versions | default versions only |                       |
diff --git a/dev/breeze/src/airflow_breeze/global_constants.py 
b/dev/breeze/src/airflow_breeze/global_constants.py
index d7fec43e8bd..15a98856e5e 100644
--- a/dev/breeze/src/airflow_breeze/global_constants.py
+++ b/dev/breeze/src/airflow_breeze/global_constants.py
@@ -38,6 +38,14 @@ from airflow_breeze.utils.path_utils import (
 
 PUBLIC_AMD_RUNNERS = '["ubuntu-22.04"]'
 PUBLIC_ARM_RUNNERS = '["ubuntu-22.04-arm"]'
+# RunsOn (runs-on.com) EC2 runner label templates. Jobs fill in their size 
class with
+# `format(<label>, cpu, ram)` in the workflow; the same call leaves the public 
labels unchanged.
+RUNS_ON_AMD_RUNNER_TEMPLATE = (
+    
"runs-on={run_id}/family=m7a+m7i+m6a+m6i/image=ubuntu22-full-x64/volume=150gb/cpu={{0}}/ram={{1}}"
+)
+RUNS_ON_ARM_RUNNER_TEMPLATE = (
+    
"runs-on={run_id}/family=m8g+m7g+m6g/image=ubuntu22-full-arm64/volume=150gb/cpu={{0}}/ram={{1}}"
+)
 
 # Platform the tests of a CI run execute on.
 CI_AMD_PLATFORM = "linux/amd64"
diff --git a/dev/breeze/src/airflow_breeze/utils/selective_checks.py 
b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
index 7a598fda8be..2d420e8ef90 100644
--- a/dev/breeze/src/airflow_breeze/utils/selective_checks.py
+++ b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
@@ -55,6 +55,8 @@ from airflow_breeze.global_constants import (
     PROVIDERS_COMPATIBILITY_TESTS_MATRIX,
     PUBLIC_AMD_RUNNERS,
     PUBLIC_ARM_RUNNERS,
+    RUNS_ON_AMD_RUNNER_TEMPLATE,
+    RUNS_ON_ARM_RUNNER_TEMPLATE,
     TESTABLE_CORE_INTEGRATIONS,
     TESTABLE_PROVIDERS_INTEGRATION_OWNERS,
     TESTABLE_PROVIDERS_INTEGRATIONS,
@@ -95,6 +97,7 @@ LATEST_VERSIONS_ONLY_LABEL = "latest versions only"
 NON_COMMITTER_BUILD_LABEL = "non committer build"
 UPGRADE_TO_NEWER_DEPENDENCIES_LABEL = "upgrade to newer dependencies"
 USE_PUBLIC_RUNNERS_LABEL = "use public runners"
+USE_SELF_HOSTED_RUNNERS_LABEL = "use self-hosted runners"
 ALLOW_PROVIDER_DEPENDENCY_BUMP_LABEL = "allow provider dependency bump"
 SKIP_COMMON_COMPAT_CHECK_LABEL = "skip common compat check"
 AREA_E2E_TESTS_LABEL = "area:e2e-tests"
@@ -2179,6 +2182,119 @@ class SelectiveChecks:
     def arm_runners(self) -> str:
         return PUBLIC_ARM_RUNNERS
 
+    @cached_property
+    def runner_type(self) -> str:
+        """Runner labels for the jobs of a CI run on this run's platform.
+
+        The RunsOn label is a template the jobs fill in with their size class.
+        """
+        # RunsOn needs the run id in the label; without one (e.g. a local run) 
the jobs would never start.
+        run_id = self._github_context_dict.get("run_id")
+        if not run_id or not self._use_runs_on_runners():
+            return PUBLIC_ARM_RUNNERS if self._platform == CI_ARM_PLATFORM 
else PUBLIC_AMD_RUNNERS
+        template = (
+            RUNS_ON_ARM_RUNNER_TEMPLATE if self._platform == CI_ARM_PLATFORM 
else RUNS_ON_AMD_RUNNER_TEMPLATE
+        )
+        return json.dumps([template.format(run_id=run_id)])
+
+    def _use_runs_on_runners(self) -> bool:
+        """Whether this run should use the self-hosted RunsOn runners.
+
+        This only routes runs; it does not keep fork code off RunsOn. A 
``pull_request`` run uses
+        the PR's own workflow files and breeze, so a fork can request a RunsOn 
label directly -
+        that restriction has to be enforced by the RunsOn stack and the 
repository's Actions
+        settings, not here. Routed there: pushes, schedules and dispatches, 
and PRs from
+        apache/airflow branches except bots' (e.g. Dependabot's). A fork PR 
goes there while it
+        carries the ``use self-hosted runners`` label, a committer has 
approved its head commit and
+        no committer's request for changes is outstanding.
+        The repository variable ``USE_PUBLIC_RUNNERS=true`` moves every run 
back to public runners.
+        """
+        if self._github_repository != APACHE_AIRFLOW_GITHUB_REPOSITORY:
+            return False
+        if os.environ.get("USE_PUBLIC_RUNNERS", "").casefold() == "true":
+            return False
+        if USE_PUBLIC_RUNNERS_LABEL in self._pr_labels:
+            return False
+        if self._github_event in [GithubEvents.PUSH, GithubEvents.SCHEDULE, 
GithubEvents.WORKFLOW_DISPATCH]:
+            return True
+        if self._github_event != GithubEvents.PULL_REQUEST:
+            return False
+        if USE_SELF_HOSTED_RUNNERS_LABEL in self._pr_labels and 
self._is_self_hosted_label_approved():
+            return True
+        if NON_COMMITTER_BUILD_LABEL in self._pr_labels:
+            return False
+        pull_request = self._github_context_dict.get("event", 
{}).get("pull_request", {})
+        # The PR author, not github.actor: a committer rebasing a Dependabot 
PR becomes the actor.
+        # The branch too: anyone can open their own PR from a Dependabot 
branch.
+        if (pull_request.get("user") or {}).get("type") == "Bot" or (
+            pull_request.get("head", {}).get("ref", 
"").startswith("dependabot/")
+        ):
+            return False
+        # A PR from a deleted fork has no head repo.
+        head_repo = (pull_request.get("head", {}).get("repo") or 
{}).get("full_name")
+        return head_repo == APACHE_AIRFLOW_GITHUB_REPOSITORY
+
+    def _is_self_hosted_label_approved(self) -> bool:
+        """Whether the PR still has the label, a committer approved its head 
commit and no committer's veto stands.
+
+        A re-run reuses the labels of its original event, so a later removal 
only shows in the PR
+        itself. The label outlives the commit it was added for, so on its own 
it would vouch for
+        every later push. A request for changes stays in force across pushes, 
as on GitHub, until
+        that committer reviews again or it is dismissed. Any failure to tell 
counts as "no".
+        """
+        # Lazy: breeze keeps its top-level imports minimal 
(check-breeze-top-dependencies-limited).
+        import requests
+
+        pull_request = self._github_context_dict.get("event", 
{}).get("pull_request", {})
+        head_sha = pull_request.get("head", {}).get("sha")
+        if not head_sha:
+            return False
+        pull_request_url = (
+            
f"https://api.github.com/repos/{self._github_repository}/pulls/{pull_request.get('number')}"
+        )
+        headers = {"Accept": "application/vnd.github+json"}
+        if token := os.environ.get("GITHUB_TOKEN"):
+            headers["Authorization"] = f"Bearer {token}"
+        # Each reviewer's latest verdict, on the head commit and on any 
commit; a dismissed review is
+        # listed as DISMISSED in place, and comment-only reviews change 
nothing.
+        latest_on_head: dict[str, str] = {}
+        latest_overall: dict[str, str] = {}
+        try:
+            response = requests.get(pull_request_url, headers=headers, 
timeout=30)
+            response.raise_for_status()
+            if USE_SELF_HOSTED_RUNNERS_LABEL not in {label["name"] for label 
in response.json()["labels"]}:
+                console_print(
+                    f"[warning]Ignoring the {USE_SELF_HOSTED_RUNNERS_LABEL!r} 
label: the PR no longer has it"
+                )
+                return False
+            url: str | None = f"{pull_request_url}/reviews?per_page=100"
+            while url:
+                response = requests.get(url, headers=headers, timeout=30)
+                response.raise_for_status()
+                for review in response.json():
+                    if review.get("state") not in {"APPROVED", 
"CHANGES_REQUESTED", "DISMISSED"}:
+                        continue
+                    login = ((review.get("user") or {}).get("login") or 
"").casefold()
+                    latest_overall[login] = review["state"]
+                    if review.get("commit_id") == head_sha:
+                        latest_on_head[login] = review["state"]
+                url = response.links.get("next", {}).get("url")
+        # AttributeError / KeyError / TypeError: a 200 whose body is not the 
expected PR or list of reviews.
+        except (requests.RequestException, AttributeError, KeyError, 
TypeError) as e:
+            console_print(f"[warning]Could not read the labels and reviews of 
the PR: {e}")
+            return False
+        committers = {committer.casefold() for committer in COMMITTERS}
+        approved = any(latest_on_head.get(committer) == "APPROVED" for 
committer in committers)
+        vetoed = any(latest_overall.get(committer) == "CHANGES_REQUESTED" for 
committer in committers)
+        if approved and not vetoed:
+            return True
+        console_print(
+            f"[warning]Ignoring the {USE_SELF_HOSTED_RUNNERS_LABEL!r} label: "
+            f"the head commit {head_sha} is not approved by a committer, or a 
committer's request for "
+            "changes stands"
+        )
+        return False
+
     @cached_property
     def has_migrations(self) -> bool:
         return any([file.startswith("airflow-core/src/airflow/migrations/") 
for file in self._files])
diff --git a/dev/breeze/tests/test_selective_checks.py 
b/dev/breeze/tests/test_selective_checks.py
index 0e19fe1f6a5..90ee9560f0d 100644
--- a/dev/breeze/tests/test_selective_checks.py
+++ b/dev/breeze/tests/test_selective_checks.py
@@ -23,6 +23,7 @@ from typing import Any
 from unittest.mock import Mock, patch
 
 import pytest
+import requests
 from rich.console import Console
 
 from airflow_breeze.global_constants import (
@@ -30,6 +31,7 @@ from airflow_breeze.global_constants import (
     ALLOWED_PYTHON_MAJOR_MINOR_VERSIONS,
     CI_AMD_PLATFORM,
     CI_ARM_PLATFORM,
+    COMMITTERS,
     CURRENT_PYTHON_MAJOR_MINOR_VERSIONS,
     DEFAULT_KUBERNETES_VERSION,
     DEFAULT_PYTHON_MAJOR_MINOR_VERSION,
@@ -4905,3 +4907,412 @@ def 
test_providers_with_test_side_effects_are_shared_process_providers():
     for provider in PROVIDERS_WITH_PROCESS_GLOBAL_TEST_SIDE_EFFECTS:
         assert provider in get_available_distributions()
         assert provider not in LONG_RUNNING_TEST_PROVIDERS
+
+
+RUNS_ON_AMD = 
'["runs-on=42/family=m7a+m7i+m6a+m6i/image=ubuntu22-full-x64/volume=150gb/cpu={0}/ram={1}"]'
+RUNS_ON_ARM = 
'["runs-on=42/family=m8g+m7g+m6g/image=ubuntu22-full-arm64/volume=150gb/cpu={0}/ram={1}"]'
+
+
+HEAD_SHA = "a" * 40
+
+
[email protected](autouse=True)
+def _no_use_public_runners_variable(monkeypatch):
+    monkeypatch.delenv("USE_PUBLIC_RUNNERS", raising=False)
+
+
+def _pull_request_context(head_repo: str | None, author_type: str = "User") -> 
dict[str, Any]:
+    head = {"repo": {"full_name": head_repo} if head_repo else None, "sha": 
HEAD_SHA}
+    return {
+        "run_id": 42,
+        "event": {"pull_request": {"number": 7, "head": head, "user": {"type": 
author_type}}},
+    }
+
+
[email protected](
+    ("github_event", "github_repository", "github_context_dict", "pr_labels", 
"platform", "expected"),
+    [
+        pytest.param(
+            GithubEvents.PUSH,
+            "apache/airflow",
+            {"run_id": 42},
+            (),
+            CI_AMD_PLATFORM,
+            RUNS_ON_AMD,
+            id="canary push uses RunsOn",
+        ),
+        pytest.param(
+            GithubEvents.SCHEDULE,
+            "apache/airflow",
+            {"run_id": 42},
+            (),
+            CI_ARM_PLATFORM,
+            RUNS_ON_ARM,
+            id="scheduled ARM canary uses ARM RunsOn",
+        ),
+        pytest.param(
+            GithubEvents.WORKFLOW_DISPATCH,
+            "apache/airflow",
+            {"run_id": 42},
+            (),
+            CI_AMD_PLATFORM,
+            RUNS_ON_AMD,
+            id="workflow_dispatch uses RunsOn",
+        ),
+        pytest.param(
+            GithubEvents.PULL_REQUEST,
+            "apache/airflow",
+            _pull_request_context("apache/airflow", author_type="Bot"),
+            (),
+            CI_AMD_PLATFORM,
+            '["ubuntu-22.04"]',
+            id="bot PR from an apache/airflow branch uses public runners",
+        ),
+        pytest.param(
+            GithubEvents.PULL_REQUEST,
+            "apache/airflow",
+            {
+                "run_id": 42,
+                "event": {
+                    "pull_request": {
+                        "head": {"ref": "dependabot/uv/foo", "repo": 
{"full_name": "apache/airflow"}},
+                        "user": {"type": "User"},
+                    }
+                },
+            },
+            (),
+            CI_AMD_PLATFORM,
+            '["ubuntu-22.04"]',
+            id="user PR from a Dependabot branch uses public runners",
+        ),
+        pytest.param(
+            GithubEvents.PULL_REQUEST,
+            "apache/airflow",
+            _pull_request_context("apache/airflow", author_type="Bot"),
+            ("use self-hosted runners", "non committer build"),
+            CI_AMD_PLATFORM,
+            RUNS_ON_AMD,
+            id="committer's self-hosted label wins over bot author and non 
committer build",
+        ),
+        pytest.param(
+            GithubEvents.PUSH,
+            "apache/airflow",
+            {},
+            (),
+            CI_AMD_PLATFORM,
+            '["ubuntu-22.04"]',
+            id="push without a run id stays on public runners",
+        ),
+        pytest.param(
+            GithubEvents.PULL_REQUEST,
+            "apache/airflow",
+            _pull_request_context("someone/airflow"),
+            ("use self-hosted runners", "use public runners"),
+            CI_AMD_PLATFORM,
+            '["ubuntu-22.04"]',
+            id="public runners label wins over the self-hosted label",
+        ),
+        pytest.param(
+            GithubEvents.PUSH,
+            "someone/airflow",
+            {"run_id": 42},
+            (),
+            CI_AMD_PLATFORM,
+            '["ubuntu-22.04"]',
+            id="push in a fork repository stays on public runners",
+        ),
+        pytest.param(
+            GithubEvents.SCHEDULE,
+            "someone/airflow",
+            {"run_id": 42},
+            (),
+            CI_ARM_PLATFORM,
+            '["ubuntu-22.04-arm"]',
+            id="schedule in a fork repository stays on public ARM runners",
+        ),
+        pytest.param(
+            GithubEvents.PULL_REQUEST,
+            "apache/airflow",
+            _pull_request_context("apache/airflow"),
+            (),
+            CI_AMD_PLATFORM,
+            RUNS_ON_AMD,
+            id="PR from an apache/airflow branch uses RunsOn",
+        ),
+        pytest.param(
+            GithubEvents.PULL_REQUEST,
+            "apache/airflow",
+            _pull_request_context("someone/airflow"),
+            (),
+            CI_AMD_PLATFORM,
+            '["ubuntu-22.04"]',
+            id="PR from a fork uses public runners",
+        ),
+        pytest.param(
+            GithubEvents.PULL_REQUEST,
+            "apache/airflow",
+            _pull_request_context(None),
+            (),
+            CI_AMD_PLATFORM,
+            '["ubuntu-22.04"]',
+            id="PR from a deleted fork uses public runners",
+        ),
+        pytest.param(
+            GithubEvents.PULL_REQUEST,
+            "apache/airflow",
+            _pull_request_context("someone/airflow"),
+            ("use self-hosted runners",),
+            CI_AMD_PLATFORM,
+            RUNS_ON_AMD,
+            id="PR from a fork with the self-hosted label uses RunsOn",
+        ),
+        pytest.param(
+            GithubEvents.PULL_REQUEST,
+            "apache/airflow",
+            _pull_request_context("someone/airflow"),
+            ("canary", "full tests needed"),
+            CI_AMD_PLATFORM,
+            '["ubuntu-22.04"]',
+            id="canary label does not route a fork PR to RunsOn",
+        ),
+        pytest.param(
+            GithubEvents.PULL_REQUEST,
+            "apache/airflow",
+            _pull_request_context("apache/airflow"),
+            ("use public runners",),
+            CI_AMD_PLATFORM,
+            '["ubuntu-22.04"]',
+            id="public runners label opts an apache/airflow PR out",
+        ),
+        pytest.param(
+            GithubEvents.PULL_REQUEST,
+            "apache/airflow",
+            _pull_request_context("apache/airflow"),
+            ("non committer build",),
+            CI_AMD_PLATFORM,
+            '["ubuntu-22.04"]',
+            id="non committer build label treats an apache/airflow PR as a 
fork PR",
+        ),
+        pytest.param(
+            GithubEvents.PULL_REQUEST_TARGET,
+            "apache/airflow",
+            _pull_request_context("someone/airflow"),
+            (),
+            CI_AMD_PLATFORM,
+            '["ubuntu-22.04"]',
+            id="pull_request_target uses public runners",
+        ),
+    ],
+)
[email protected](SelectiveChecks, "_is_self_hosted_label_approved", 
autospec=True, return_value=True)
+def test_runner_type(
+    _mock_is_self_hosted_label_approved,
+    github_event: GithubEvents,
+    github_repository: str,
+    github_context_dict: dict[str, Any],
+    pr_labels: tuple[str, ...],
+    platform: str,
+    expected: str,
+):
+    selective_checks = SelectiveChecks(
+        github_event=github_event,
+        github_repository=github_repository,
+        github_context_dict=github_context_dict,
+        pr_labels=pr_labels,
+        platform=platform,
+    )
+    assert selective_checks.runner_type == expected
+
+
[email protected](
+    ("value", "expected"),
+    [
+        pytest.param("true", '["ubuntu-22.04"]', id="true moves a push to 
public runners"),
+        pytest.param("True", '["ubuntu-22.04"]', id="case does not matter"),
+        pytest.param("false", RUNS_ON_AMD, id="false keeps RunsOn"),
+        pytest.param("", RUNS_ON_AMD, id="an unset variable keeps RunsOn"),
+    ],
+)
+def test_use_public_runners_variable(monkeypatch, value: str, expected: str):
+    monkeypatch.setenv("USE_PUBLIC_RUNNERS", value)
+    selective_checks = SelectiveChecks(
+        github_event=GithubEvents.PUSH, github_repository="apache/airflow", 
github_context_dict={"run_id": 42}
+    )
+    assert selective_checks.runner_type == expected
+
+
[email protected](SelectiveChecks, "_is_self_hosted_label_approved", 
autospec=True, return_value=True)
+def test_use_public_runners_variable_beats_the_self_hosted_label(
+    _mock_is_self_hosted_label_approved, monkeypatch
+):
+    monkeypatch.setenv("USE_PUBLIC_RUNNERS", "true")
+    selective_checks = SelectiveChecks(
+        github_event=GithubEvents.PULL_REQUEST,
+        github_repository="apache/airflow",
+        github_context_dict=_pull_request_context("apache/airflow"),
+        pr_labels=("use self-hosted runners",),
+    )
+    assert selective_checks.runner_type == '["ubuntu-22.04"]'
+
+
+def _review(login: str, state: str, commit_id: str = HEAD_SHA) -> dict[str, 
Any]:
+    return {"user": {"login": login}, "state": state, "commit_id": commit_id}
+
+
[email protected](
+    ("pages", "expected"),
+    [
+        pytest.param([[_review(COMMITTERS[0], "APPROVED")]], True, 
id="approved by a committer"),
+        pytest.param(
+            [[_review(COMMITTERS[0].swapcase(), "APPROVED")]],
+            True,
+            id="approved by a committer whose login differs in case",
+        ),
+        pytest.param([[_review("a-triager", "APPROVED")]], False, id="approved 
by a non-committer"),
+        pytest.param(
+            [[_review(COMMITTERS[0], "APPROVED", commit_id="b" * 40)]],
+            False,
+            id="approved an earlier commit only",
+        ),
+        pytest.param(
+            [[_review(COMMITTERS[0], "APPROVED"), _review(COMMITTERS[0], 
"CHANGES_REQUESTED")]],
+            False,
+            id="changes requested after approving",
+        ),
+        pytest.param(
+            [[_review(COMMITTERS[0], "CHANGES_REQUESTED"), 
_review(COMMITTERS[0], "APPROVED")]],
+            True,
+            id="approved after requesting changes",
+        ),
+        pytest.param(
+            [[_review(COMMITTERS[0], "APPROVED"), _review(COMMITTERS[0], 
"DISMISSED")]],
+            False,
+            id="approval dismissed",
+        ),
+        pytest.param(
+            [[_review(COMMITTERS[0], "APPROVED"), _review(COMMITTERS[0], 
"COMMENTED")]],
+            True,
+            id="a later comment keeps the approval",
+        ),
+        pytest.param(
+            [[_review("a-triager", "APPROVED")], [_review(COMMITTERS[0], 
"APPROVED")]],
+            True,
+            id="approved on a later page",
+        ),
+        pytest.param(
+            [[_review(COMMITTERS[0], "APPROVED"), _review(COMMITTERS[1], 
"CHANGES_REQUESTED")]],
+            False,
+            id="another committer's request for changes vetoes an approval",
+        ),
+        pytest.param(
+            [[_review(COMMITTERS[0], "APPROVED"), _review("a-triager", 
"CHANGES_REQUESTED")]],
+            True,
+            id="a non-committer's request for changes does not veto",
+        ),
+        pytest.param(
+            [
+                [
+                    _review(COMMITTERS[1], "CHANGES_REQUESTED", commit_id="b" 
* 40),
+                    _review(COMMITTERS[0], "APPROVED"),
+                ]
+            ],
+            False,
+            id="a committer's request for changes on an earlier commit still 
vetoes",
+        ),
+        pytest.param(
+            [
+                [
+                    _review(COMMITTERS[1], "CHANGES_REQUESTED", commit_id="b" 
* 40),
+                    _review(COMMITTERS[1], "APPROVED", commit_id="b" * 40),
+                    _review(COMMITTERS[0], "APPROVED"),
+                ]
+            ],
+            True,
+            id="a request for changes withdrawn by its committer's later 
approval does not veto",
+        ),
+        pytest.param([[]], False, id="no review"),
+        pytest.param([{"message": "Not Found"}], False, id="error object 
instead of reviews"),
+    ],
+)
+@patch("requests.get", autospec=True)
+def test_is_self_hosted_label_approved(mock_get, pages: list[list[dict[str, 
Any]]], expected: bool):
+    pull_request = Mock(spec=requests.Response)
+    pull_request.json.return_value = {"labels": [{"name": "use self-hosted 
runners"}]}
+    responses = [pull_request]
+    for index, page in enumerate(pages):
+        response = Mock(spec=requests.Response)
+        response.json.return_value = page
+        response.links = {"next": {"url": f"page-{index + 1}"}} if index + 1 < 
len(pages) else {}
+        responses.append(response)
+    mock_get.side_effect = responses
+    selective_checks = SelectiveChecks(
+        github_context_dict=_pull_request_context("someone/airflow"),
+        pr_labels=("use self-hosted runners",),
+    )
+    assert selective_checks._is_self_hosted_label_approved() is expected
+    assert [call.args[0] for call in mock_get.call_args_list[:2]] == [
+        "https://api.github.com/repos/apache/airflow/pulls/7";,
+        
"https://api.github.com/repos/apache/airflow/pulls/7/reviews?per_page=100";,
+    ]
+
+
[email protected](
+    "pull_request_body",
+    [
+        pytest.param({"labels": [{"name": "full tests needed"}]}, id="label 
removed since the event"),
+        pytest.param({"message": "Not Found"}, id="error object instead of the 
PR"),
+    ],
+)
+@patch("requests.get", autospec=True)
+def test_is_self_hosted_label_approved_needs_the_current_label(mock_get, 
pull_request_body: dict[str, Any]):
+    # A re-run reuses the labels of its original event, so the label is still 
in pr_labels.
+    response = Mock(spec=requests.Response)
+    response.json.return_value = pull_request_body
+    mock_get.return_value = response
+    selective_checks = SelectiveChecks(
+        github_context_dict=_pull_request_context("someone/airflow"),
+        pr_labels=("use self-hosted runners",),
+    )
+    assert selective_checks._is_self_hosted_label_approved() is False
+    mock_get.assert_called_once()
+
+
+@patch("requests.get", autospec=True)
+def test_is_self_hosted_label_approved_without_head_sha(mock_get):
+    # A review whose commit_id is missing must not match a missing head sha.
+    response = Mock(spec=requests.Response)
+    response.json.return_value = [{"user": {"login": COMMITTERS[0]}, "state": 
"APPROVED"}]
+    response.links = {}
+    mock_get.return_value = response
+    context = _pull_request_context("someone/airflow")
+    del context["event"]["pull_request"]["head"]["sha"]
+    selective_checks = SelectiveChecks(github_context_dict=context, 
pr_labels=("use self-hosted runners",))
+    assert selective_checks._is_self_hosted_label_approved() is False
+    mock_get.assert_not_called()
+
+
[email protected](
+    ("head_repo", "expected"),
+    [
+        pytest.param("someone/airflow", '["ubuntu-22.04"]', id="fork PR falls 
back to public runners"),
+        pytest.param("apache/airflow", RUNS_ON_AMD, id="apache/airflow PR 
stays on RunsOn"),
+    ],
+)
[email protected](
+    "failure",
+    [
+        pytest.param(requests.ConnectionError("down"), id="connection error"),
+        pytest.param(requests.HTTPError("403"), id="non-2xx response"),
+    ],
+)
+@patch("requests.get", autospec=True)
+def test_self_hosted_label_ignored_when_reviews_cannot_be_read(
+    mock_get, failure: Exception, head_repo: str, expected: str
+):
+    mock_get.side_effect = failure
+    selective_checks = SelectiveChecks(
+        github_event=GithubEvents.PULL_REQUEST,
+        github_context_dict=_pull_request_context(head_repo),
+        pr_labels=("use self-hosted runners",),
+    )
+    assert selective_checks.runner_type == expected
diff --git a/scripts/ci/make_mnt_writeable.sh b/scripts/ci/make_mnt_writeable.sh
index ddabf84b6b7..ed1e4d044ce 100755
--- a/scripts/ci/make_mnt_writeable.sh
+++ b/scripts/ci/make_mnt_writeable.sh
@@ -22,13 +22,26 @@ function make_mnt_writeable {
     sudo blkid
     echo "Checking free space!"
     df -H
-    echo "Cleaning /mnt just in case it is not empty"
+    echo "Cleaning /mnt just in case it is not empty and making sure that /mnt 
is writeable"
     sudo mkdir -p /mnt
-    sudo rm -rf /mnt/*
+    if [[ "${RUNNER_ENVIRONMENT:-}" == "self-hosted" ]]; then
+        # RunsOn mounts an instance's local NVMe disks under /mnt and keeps 
docker, /home/runner and
+        # /tmp on them, so emptying or re-owning such a mount would break the 
running job.
+        local entry
+        for entry in /mnt/*; do
+            if mountpoint -q "${entry}"; then
+                echo "Keeping mount point ${entry}"
+            else
+                sudo rm -rf --one-file-system "${entry}"
+            fi
+        done
+        sudo chown "${USER}" /mnt
+    else
+        sudo rm -rf /mnt/*
+        sudo chown -R "${USER}" /mnt
+    fi
     echo "Checking free space!"
     df -H
-    echo "Making sure that /mnt is writeable"
-    sudo chown -R "${USER}" /mnt
 }
 
 make_mnt_writeable
diff --git a/scripts/ci/move_docker_to_mnt.sh b/scripts/ci/move_docker_to_mnt.sh
index e3d69ef60be..4fa5e4a0711 100755
--- a/scripts/ci/move_docker_to_mnt.sh
+++ b/scripts/ci/move_docker_to_mnt.sh
@@ -29,7 +29,12 @@ function cleanup_runner {
     # Hence we only move docker to /mnt on x86_64 where we have a separate 
/mnt mount
     # If we get short on disk space on arm64 as well we need to revisit this 
logic
     # and make the idle nvme being used as well.
-    if uname -i|grep -q x86_64; then
+    if [[ "${RUNNER_ENVIRONMENT:-}" == "self-hosted" ]]; then
+        # RunsOn keeps docker either on the instance's local NVMe disks, which 
may also back
+        # /home/runner and /tmp, or - on instance types without them - on the 
same EBS root
+        # volume /mnt is on. Moving it gains nothing and could delete the 
running job's files.
+        echo "Self-hosted runner - keeping /var/lib/docker where it is"
+    elif uname -i|grep -q x86_64; then
         local target_docker_volume_location="/mnt/var-lib-docker"
         # This is faster than docker prune
         echo "Stopping docker"
diff --git a/scripts/ci/prek/check_ci_workflows_in_sync.py 
b/scripts/ci/prek/check_ci_workflows_in_sync.py
index 134df167f1d..321ca94194c 100755
--- a/scripts/ci/prek/check_ci_workflows_in_sync.py
+++ b/scripts/ci/prek/check_ci_workflows_in_sync.py
@@ -37,8 +37,8 @@ Documented divergences:
    its ``gate`` job runs selective tests only for pushes to the branches
    in CANONICAL_PUSH_BRANCH_FILTERS (fork_ci_gate.py).
 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")
+5. ``build-info`` output ``platform`` — hardcoded per architecture (and the
+   surrounding comment naming the "ARM/AMD copy")
 6. ``PLATFORM`` env on the ``selective-checks`` step — the platform this
    workflow's tests run on.
 7. ``print-platform`` job — ``name:`` and the architecture echoed to
@@ -96,7 +96,6 @@ LINE_RULES: list[tuple[str, str]] = [
         "    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"'),
     (
         r"^      # (?:ARM|AMD) copy\)\. The matching (?:AMD|ARM) copy lives in 
ci-(?:amd|arm)\.yml\.$",

Reply via email to