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

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


The following commit(s) were added to refs/heads/main by this push:
     new 115eb43aba8 Build docs and registry CI images separately so docs stay 
on default Python (#70959)
115eb43aba8 is described below

commit 115eb43aba8b7989be377e682f6cdb2aedb631a0
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Aug 3 21:17:44 2026 +0200

    Build docs and registry CI images separately so docs stay on default Python 
(#70959)
    
    * Build docs and registry CI images separately so docs stay on default 
Python
    
    The docs build and the provider registry shared a single CI image, so they 
also
    shared its Python. That image is built at 3.12 because the registry has 
always
    extracted on 3.12, which silently moved the docs build there too and broke
    publishing on 2026-07-28: Sphinx mocks third-party modules while building
    provider docs, and from 3.12 functools copies __type_params__, for which a 
mock
    hands back another mock rather than a tuple. Any provider decorating methods
    with functools.wraps over a mocked callable then fails to import, and the 
build
    also began reporting cross-reference ambiguities it had not reported before.
    
    The image job already matrixes over the Python versions it is given, and 
every
    cache it touches - registry buildx tag, image stash, commit marker and mount
    cache - is keyed by Python version. Asking it for both versions therefore 
builds
    them in parallel, each seeding and storing its own cache, and needs no 
second
    job. Docs go back to the default Python and the registry keeps 3.12.
    
    The stashes are also named for what they are rather than for the ref alone, 
so
    release images stop sharing a key with anything else that stashes an image 
for
    the same ref.
    
    * Build each CI image in the job that uses it instead of a shared job
    
    Docs and the registry now want different Pythons, and a job that builds an 
image
    for another job has to hand it over as a multi-gigabyte tarball - exported,
    stashed, restored and imported - for an image whose only consumer is a 
single
    downstream job. Building in place skips that round trip entirely, and the 
two
    builds still run in parallel because both jobs now wait only on build-info.
    
    What is worth keeping between runs is the BuildKit mount cache rather than 
the
    image, so the docs job restores and stashes it under this ref and its own
    Python. The registry side already knew how to build its own image when the
    caller had not stashed one; it just could not be told which ref to build, 
so it
    would have documented main rather than the tag being published.
    
    * Pin the docs build to the default Python again
    
    `breeze build-docs` gained a --python option, and because that option reads
    PYTHON_MAJOR_MINOR_VERSION, which every job of the docs publishing workflow
    sets, the docs quietly started building on whatever the workflow happened to
    export rather than on the Python they are meant for. That is how they ended 
up
    on 3.12, where Sphinx's mocking of third-party modules makes functools.wraps
    raise and providers fail to import.
    
    Which interpreter documents Airflow is not a per-invocation choice, so the
    option is gone and the build pins itself to the default again. The workflow
    reads the same constant instead of naming a version of its own, so the 
image it
    prepares cannot drift away from the one the docs build asks for.
    
    * Name the docs publishing stashes after the workflow that writes them
    
    The prefix exists to keep these images from sharing a key with anything else
    stashing an image for the same ref, so it should say which workflow put them
    there rather than describe them as release artifacts.
    
    * Build the CI image in the job that uses it, cache and all
    
    Each image now has a single consumer, so a job that only builds one can do 
no
    more than export it and have the consumer import it straight back. Both 
docs and
    the registry build in place instead, which leaves the registry workflow 
with one
    job as well.
    
    What that job hands to the next run is unchanged: the image, the commit it 
was
    built from and the mount cache are stashed under the ref, so a later run 
for the
    same ref seeds its build from them and skips the build entirely when the 
sources
    have not moved. That is the whole reason the stash exists - publishing an 
RC and
    then the final docs is the same ref twice - so it moves into the jobs with 
the
    build rather than being dropped along with the job that used to do it.
    
    The logic lives in a composite action so both jobs share one copy;
    ci-image-build.yml stays for callers that do want an image job of their own.
    
    * Balance the registry job condition after dropping the image dependency
    
    Removing the build-ci-image guard left the parenthesis that opened its 
group, so
    GitHub refused to parse the workflow and the dispatch failed before any job 
ran.
    
    * Wait for the registry before reporting the docs publish done
    
    The registry still runs alongside the docs build - nothing it does depends 
on
    them - but the publish now waits for it, so a green run means both halves 
of a
    publish are actually out rather than only the S3 upload. Its result is 
checked
    explicitly because the registry is skipped for non-provider distributions, 
and a
    skipped dependency would otherwise skip the publish with it.
    
    * Read the image-build action from the workflow's own version
    
    A local action is resolved against the workspace, not against the ref the
    workflow definition came from, so publishing a tag cut before the action 
existed
    found nothing to run and both image builds failed immediately. The 
workflow's
    own version is now checked out alongside the sources being published and the
    action is taken from there, which is what lets a workflow branch be tested
    against an older tag at all. The image itself is still built from the 
checked
    out sources.
---
 .../actions/build_ci_image_with_cache/action.yml   | 221 +++++++++++++++++++++
 .github/workflows/publish-docs-to-s3.yml           | 143 ++++++-------
 .github/workflows/registry-build.yml               |  96 ++++-----
 dev/breeze/doc/images/output_build-docs.svg        |  44 ++--
 dev/breeze/doc/images/output_build-docs.txt        |   2 +-
 .../airflow_breeze/commands/developer_commands.py  |  13 +-
 .../commands/developer_commands_config.py          |   1 -
 dev/breeze/tests/test_developer_commands.py        |  33 +--
 8 files changed, 380 insertions(+), 173 deletions(-)

diff --git a/.github/actions/build_ci_image_with_cache/action.yml 
b/.github/actions/build_ci_image_with_cache/action.yml
new file mode 100644
index 00000000000..3309891832d
--- /dev/null
+++ b/.github/actions/build_ci_image_with_cache/action.yml
@@ -0,0 +1,221 @@
+# 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.
+#
+---
+name: 'Build CI image with cache'
+description: >
+  Builds the CI image in the job that uses it, seeding the build from what a 
previous run stashed
+  for the same ref and stashing the result for the next one. Exists so a job 
needing an image does
+  not have to take it from another job as a multi-gigabyte artifact; 
ci-image-build.yml remains the
+  reusable-workflow form for callers that do want a job of their own.
+inputs:
+  python:
+    description: 'Python version to build the image for'
+    required: true
+  platform:
+    description: 'Platform to build for'
+    default: 'linux/amd64'
+  image-stash-ref:
+    description: >
+      Discriminator the image, its commit marker and the mount cache are 
stashed under. Scoped to
+      the ref rather than the branch: these images are built from a ref that 
is not the branch tip,
+      so the shared per-branch stash would hand the next reader sources it 
never asked for.
+    required: true
+  github-token:
+    description: 'Token used to log in to ghcr.io and read the registry build 
cache'
+    required: true
+  constraints-github-repository:
+    description: 'Repository the constraints are taken from'
+    default: 'apache/airflow'
+runs:
+  using: "composite"
+  steps:
+    # A stashed image is only an answer when the sources have not moved since; 
otherwise it is
+    # cache. The commit it was built from tells the two apart and is stashed 
on its own, so
+    # deciding costs a few bytes rather than the image the decision may make 
unnecessary.
+    - name: "Restore the commit the stashed CI image was built from"
+      uses: 
apache/infrastructure-actions/stash/restore@0ff9972b5872e19c9f4555c9159c2fea4f794355
+      with:
+        key: "ci-image-commit-v3-${{ inputs.platform }}-${{ inputs.python }}\
+          -${{ inputs.image-stash-ref }}"
+        path: "/mnt/"
+        only-current-branch: 'true'
+      id: restore-commit
+    - name: "Check whether the stashed CI image was built from this commit"
+      id: stashed-image
+      env:
+        COMMIT_FILE: "/mnt/ci-image-commit-${{ inputs.python }}.txt"
+      shell: bash
+      run: |
+        head_sha="$(git rev-parse HEAD)"
+        stashed_sha="$(cat "${COMMIT_FILE}" 2>/dev/null || true)"
+        if [[ "${stashed_sha}" == "${head_sha}" ]]; then
+          echo "The stashed image was built from ${head_sha} - reusing it, 
without a build."
+          echo "reusable=true" >> "${GITHUB_OUTPUT}"
+        else
+          echo "The stashed image was built from '${stashed_sha:-unknown}', 
not ${head_sha}."
+          echo "It can only seed the build cache."
+        fi
+      if: steps.restore-commit.outputs.stash-hit == 'true'
+    # Restored ahead of the caches that feed the build, so a build made 
unnecessary skips them too.
+    - name: "Restore the CI image stashed for this ref"
+      uses: 
apache/infrastructure-actions/stash/restore@0ff9972b5872e19c9f4555c9159c2fea4f794355
+      with:
+        key: "ci-image-save-v3-${{ inputs.platform }}-${{ inputs.python }}\
+          -${{ inputs.image-stash-ref }}"
+        path: "/mnt/"
+        only-current-branch: 'true'
+      id: restore-image
+    - name: "Load the stashed CI image as the image to use"
+      shell: bash
+      env:
+        PLATFORM: ${{ inputs.platform }}
+        PYTHON: ${{ inputs.python }}
+      run: breeze ci-image load --platform "${PLATFORM}" --python "${PYTHON}" 
--image-file-dir "/mnt"
+      if: steps.stashed-image.outputs.reusable == 'true'
+    # BuildKit reads `--cache-from` from a registry, never from the local 
engine, so the restored
+    # image has to be served from one to contribute anything.
+    - name: "Serve the stashed CI image as build cache"
+      id: serve-cache
+      shell: bash
+      env:
+        PLATFORM: ${{ inputs.platform }}
+        PYTHON: ${{ inputs.python }}
+        CACHE_IMAGE: "localhost:5000/ci-image-cache:${{ inputs.python }}"
+      run: |
+        docker run -d --name cache-registry -p 5000:5000 registry:2
+        breeze ci-image load --platform "${PLATFORM}" --python "${PYTHON}" \
+          --image-file-dir "/mnt" --tag-as "${CACHE_IMAGE}"
+        docker push "${CACHE_IMAGE}"
+        echo "cache-from-image=${CACHE_IMAGE}" >> "${GITHUB_OUTPUT}"
+      if: >
+        steps.stashed-image.outputs.reusable != 'true' &&
+        steps.restore-image.outputs.stash-hit == 'true'
+    # Scoped to the ref for the same reason the image is: the mount cache 
holds the dependency set
+    # the sources resolve to, and a ref's and the branch tip's are exactly 
what differ.
+    - name: "Restore the mount cache stashed for this ref"
+      uses: 
apache/infrastructure-actions/stash/restore@0ff9972b5872e19c9f4555c9159c2fea4f794355
+      with:
+        key: "ci-cache-mount-save-v3-${{ inputs.platform }}-${{ inputs.python 
}}\
+          -${{ inputs.image-stash-ref }}"
+        path: "/tmp/"
+      id: restore-mount-cache
+      if: steps.stashed-image.outputs.reusable != 'true'
+    - name: "Import the mount cache"
+      shell: bash
+      env:
+        PYTHON_MAJOR_MINOR_VERSION: ${{ inputs.python }}
+        CACHE_FILE: "/tmp/ci-cache-mount-save-v3-${{ inputs.python }}.tar.gz"
+      run: |
+        if [[ ! -f "${CACHE_FILE}" ]]; then
+          echo "${CACHE_FILE} is missing - the stash restore may have timed 
out. Building without it."
+          exit 0
+        fi
+        breeze ci-image import-mount-cache --cache-file "${CACHE_FILE}"
+      if: >
+        steps.stashed-image.outputs.reusable != 'true' &&
+        steps.restore-mount-cache.outputs.stash-hit == 'true'
+    - name: "Login to ghcr.io"
+      shell: bash
+      env:
+        GITHUB_TOKEN: ${{ inputs.github-token }}
+        ACTOR: ${{ github.actor }}
+      run: echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${ACTOR}" 
--password-stdin
+      if: steps.stashed-image.outputs.reusable != 'true'
+    - name: "Build the CI image"
+      shell: bash
+      env:
+        PLATFORM: ${{ inputs.platform }}
+        PYTHON_MAJOR_MINOR_VERSION: ${{ inputs.python }}
+        DOCKER_CACHE: "registry"
+        DISABLE_AIRFLOW_REPO_CACHE: "false"
+        UPGRADE_TO_NEWER_DEPENDENCIES: "false"
+        CONSTRAINTS_GITHUB_REPOSITORY: ${{ 
inputs.constraints-github-repository }}
+        GITHUB_REPOSITORY: ${{ github.repository }}
+        GITHUB_TOKEN: ${{ inputs.github-token }}
+        GITHUB_USERNAME: ${{ github.actor }}
+        PUSH: "false"
+        VERBOSE: "true"
+        # Empty when nothing was stashed for this ref; breeze then builds off 
the registry cache
+        # alone. BuildKit reads --cache-from from a registry, never from the 
local engine.
+        CACHE_FROM_IMAGE: ${{ steps.serve-cache.outputs.cache-from-image }}
+      run: |
+        if breeze ci-image build --platform "${PLATFORM}"; then
+          exit 0
+        fi
+        # Reached when this ref's pyproject.toml has drifted far enough from 
the branch the
+        # registry cache was built for that the cached layers cannot satisfy 
it. Slow, but correct
+        # - and the case that matters here, since these refs are cut days or 
weeks before main.
+        echo "Build with the registry cache failed - retrying with the cache 
disabled."
+        DOCKER_CACHE="disabled" breeze ci-image build --platform "${PLATFORM}"
+      if: steps.stashed-image.outputs.reusable != 'true'
+    - name: "Stop serving the stashed image as cache"
+      # The registry holds a second copy of a multi-gigabyte image and the 
export below needs room.
+      shell: bash
+      env:
+        CACHE_FROM_IMAGE: ${{ steps.serve-cache.outputs.cache-from-image }}
+      run: |
+        docker rm -f cache-registry
+        docker rmi "${CACHE_FROM_IMAGE}"
+      if: always() && steps.serve-cache.outputs.cache-from-image != ''
+    - name: "Export the CI image for the next run"
+      shell: bash
+      env:
+        PLATFORM: ${{ inputs.platform }}
+        COMMIT_FILE: "/mnt/ci-image-commit-${{ inputs.python }}.txt"
+      run: |
+        breeze ci-image save --platform "${PLATFORM}" --image-file-dir "/mnt"
+        git rev-parse HEAD > "${COMMIT_FILE}"
+      if: steps.stashed-image.outputs.reusable != 'true'
+    - name: "Stash the CI image"
+      uses: 
apache/infrastructure-actions/stash/save@0ff9972b5872e19c9f4555c9159c2fea4f794355
+      with:
+        key: "ci-image-save-v3-${{ inputs.platform }}-${{ inputs.python }}\
+          -${{ inputs.image-stash-ref }}"
+        path: "/mnt/ci-image-save-*-${{ inputs.python }}.tar"
+        if-no-files-found: 'error'
+        # Read by the next run for this same ref - an RC and then the final, 
days apart.
+        retention-days: '6'
+      if: steps.stashed-image.outputs.reusable != 'true'
+    # 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 was built from"
+      uses: 
apache/infrastructure-actions/stash/save@0ff9972b5872e19c9f4555c9159c2fea4f794355
+      with:
+        key: "ci-image-commit-v3-${{ inputs.platform }}-${{ inputs.python }}\
+          -${{ inputs.image-stash-ref }}"
+        path: "/mnt/ci-image-commit-${{ inputs.python }}.txt"
+        if-no-files-found: 'error'
+        retention-days: '6'
+      if: steps.stashed-image.outputs.reusable != 'true'
+    - name: "Export the mount cache for the next run"
+      shell: bash
+      env:
+        PYTHON_MAJOR_MINOR_VERSION: ${{ inputs.python }}
+      run: >
+        breeze ci-image export-mount-cache
+        --cache-file 
/tmp/ci-cache-mount-save-v3-${PYTHON_MAJOR_MINOR_VERSION}.tar.gz
+      if: steps.stashed-image.outputs.reusable != 'true'
+    - name: "Stash the mount cache"
+      uses: 
apache/infrastructure-actions/stash/save@0ff9972b5872e19c9f4555c9159c2fea4f794355
+      with:
+        key: "ci-cache-mount-save-v3-${{ inputs.platform }}-${{ inputs.python 
}}\
+          -${{ inputs.image-stash-ref }}"
+        path: "/tmp/ci-cache-mount-save-v3-${{ inputs.python }}.tar.gz"
+        if-no-files-found: 'error'
+        retention-days: '6'
+      if: steps.stashed-image.outputs.reusable != 'true'
diff --git a/.github/workflows/publish-docs-to-s3.yml 
b/.github/workflows/publish-docs-to-s3.yml
index f94760b64dd..1c613209794 100644
--- a/.github/workflows/publish-docs-to-s3.yml
+++ b/.github/workflows/publish-docs-to-s3.yml
@@ -98,9 +98,18 @@ jobs:
       publish-supervisor-schema: ${{ 
steps.parameters.outputs.publish-supervisor-schema }}
       # yamllint disable rule:line-length
       skip-write-to-stable-folder: ${{ inputs.skip-write-to-stable-folder && 
'--skip-write-to-stable-folder' || '' }}
-      # The docs build and the registry extraction share one CI image, so they 
share its
-      # Python too. 3.12 is what the registry has always extracted on.
-      default-python-version: "3.12"
+      # Docs and the registry each build the image they need rather than 
sharing one. Sharing
+      # meant sharing the Python too, which put the docs on 3.12 and broke 
publishing: Sphinx
+      # mocks third-party modules, and from 3.12 functools copies 
__type_params__, so a mocked
+      # decorator raises TypeError and providers fail to import. `breeze 
build-docs` pins itself
+      # to the default Python, so this follows the same constant rather than 
restating a version.
+      docs-python-version: ${{ 
steps.default_python.outputs.default-python-version }}
+      registry-python-version: "3.12"
+      # Discriminator for every stash this workflow writes. A bare ref would 
collide with any
+      # other workflow stashing an image for the same ref, and the images here 
are special: built
+      # from a release tag rather than a branch tip, and kept for days so the 
next publish of that
+      # tag reuses them. Naming them after this workflow keeps them 
recognisably ours.
+      image-stash-ref: "publish-docs-${{ inputs.ref }}"
       registry-providers: ${{ 
steps.derive_registry_inputs.outputs.registry-providers }}
       registry-full-build: ${{ 
steps.derive_registry_inputs.outputs.registry-full-build }}
     if: contains(fromJSON('[
@@ -165,6 +174,21 @@ jobs:
             exit 0
           fi
           python3 dev/registry/derive_wave_providers.py
+      - name: "Derive the default Python version"
+        id: default_python
+        shell: bash
+        # `breeze build-docs` always documents on breeze's default Python, so 
the image this
+        # workflow builds for it has to be that same one. Reading the constant 
keeps the two from
+        # drifting apart and silently making the docs job build an image 
nothing then uses.
+        run: |
+          DEFAULT_PYTHON=$(python3 -c "
+          import re, pathlib
+          text = 
pathlib.Path('dev/breeze/src/airflow_breeze/global_constants.py').read_text()
+          versions = 
re.search(r'^ALL_PYTHON_MAJOR_MINOR_VERSIONS\s*=\s*\[(.*?)\]', text, re.M | 
re.S)
+          print(re.findall(r'\"([0-9]+\.[0-9]+)\"', versions.group(1))[0])
+          ")
+          echo "Default Python version: '${DEFAULT_PYTHON}'"
+          echo "default-python-version=${DEFAULT_PYTHON}" >> "${GITHUB_OUTPUT}"
       - name: "Input parameters summary"
         shell: bash
         id: parameters
@@ -228,46 +252,8 @@ jobs:
           echo "publish-execution-api-schema=${PUBLISH_EXEC}" >> 
${GITHUB_OUTPUT}
           echo "publish-supervisor-schema=${PUBLISH_SUP}" >> ${GITHUB_OUTPUT}
 
-  build-ci-image:
-    name: "Build CI image"
-    needs: [build-info]
-    uses: ./.github/workflows/ci-image-build.yml
-    permissions:
-      contents: read
-      packages: write
-    with:
-      runners: '["ubuntu-22.04"]'
-      platform: "linux/amd64"
-      # Built from the docs ref so the image matches the sources being 
documented, and
-      # cached against main's registry cache, which the regular Test workflow 
keeps warm.
-      checkout-ref: ${{ inputs.ref }}
-      push-image: "false"
-      upload-image-artifact: "true"
-      # Leaves the BuildKit mount cache behind for the next publish of this 
same ref;
-      # ci-image-build.yml always restores it, so consecutive publishes stop 
re-downloading
-      # the whole dependency set.
-      upload-mount-cache-artifact: "true"
-      # Docs are published from refs cut days or weeks before main, and main's 
registry cache
-      # stops matching at the first Dockerfile.ci change made since the cut - 
#70285 alone
-      # costs a from-scratch Python build. The image the previous publish 
stashed was built
-      # from these very sources, so it is the cache main's cannot be. 
Publishing the same ref
-      # twice - an RC and then the final docs - skips the build outright.
-      seed-cache-from-stashed-image: "true"
-      image-stash-ref: ${{ inputs.ref }}
-      python-versions: ${{ format('["{0}"]', 
needs.build-info.outputs.default-python-version) }}
-      branch: "main"
-      constraints-branch: "constraints-main"
-      use-uv: "true"
-      upgrade-to-newer-dependencies: "false"
-      docker-cache: "registry"
-      disable-airflow-repo-cache: "false"
-      # Building against main's cache can fail outright when the ref's 
pyproject.toml has
-      # diverged from main — the case the docs job's inline `breeze ci-image 
build || docker
-      # buildx build` fallback used to cover.
-      retry-without-cache: "true"
-
   build-docs:
-    needs: [build-info, build-ci-image]
+    needs: [build-info]
     timeout-minutes: 150
     name: "Build documentation"
     runs-on: ubuntu-latest
@@ -282,7 +268,7 @@ jobs:
       VERBOSE: "true"
       EXTRA_BUILD_OPTIONS: ${{ needs.build-info.outputs.extra-build-options }}
       APPLY_COMMITS: ${{ inputs.apply-commits || '' }}
-      PYTHON_MAJOR_MINOR_VERSION: "${{ 
needs.build-info.outputs.default-python-version }}"
+      PYTHON_MAJOR_MINOR_VERSION: "${{ 
needs.build-info.outputs.docs-python-version }}"
       DOCKER_CACHE: "registry"
     steps:
       - name: "Cleanup repo"
@@ -313,6 +299,16 @@ jobs:
           ref: ${{ inputs.ref }}
           fetch-tags: true
           fetch-depth: 0
+      # A local action is read from the workspace, not from the ref the 
workflow itself runs from,
+      # so an action added after this tag was cut is simply absent. Checking 
the workflow's own
+      # version out alongside supplies the definition; the image is still 
built from the sources
+      # above. It has to come after the checkout that populates the root, 
which cleans untracked
+      # siblings away.
+      - name: "Checkout the workflow version for its actions"
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
+        with:
+          persist-credentials: false
+          path: workflow-version
       - name: "Apply patch commits if provided"
         run: |
           if [[ "${APPLY_COMMITS}" != "" ]]; then
@@ -332,26 +328,21 @@ jobs:
       - name: "Install Breeze from the ${{ inputs.ref }} reference"
         uses: ./.github/actions/breeze
         with:
-          python-version: "${{ needs.build-info.outputs.default-python-version 
}}"
-      # The image comes from the `build-ci-image` job, which builds it from 
this same ref and
-      # stashes it under the ref's own key - the shared per-branch stash holds 
main's image,
-      # not this ref's. The registry build restores the very same stash, so a 
run builds it
-      # once, and a run that had nothing to build restores what the previous 
publish left.
-      - name: >
-          Restore CI docker image built for ref ${{ inputs.ref }}
-          linux/amd64:${{ needs.build-info.outputs.default-python-version }}
-        uses: 
apache/infrastructure-actions/stash/restore@0ff9972b5872e19c9f4555c9159c2fea4f794355
+          python-version: "${{ needs.build-info.outputs.docs-python-version }}"
+      # Built here rather than in a job of its own: a separate job could only 
hand the image over
+      # as a multi-gigabyte artifact, for a single consumer. The image, the 
commit it was built
+      # from and the mount cache are still stashed under this ref, so the next 
publish of the same
+      # ref seeds its build from them - or skips the build outright when the 
sources have not moved.
+      - name: "Build CI image linux/amd64:${{ 
needs.build-info.outputs.docs-python-version }}"
+        uses: ./workflow-version/.github/actions/build_ci_image_with_cache
         with:
-          key: "ci-image-save-v3-linux/amd64-\
-            ${{ needs.build-info.outputs.default-python-version }}-${{ 
inputs.ref }}"
-          path: "/mnt/"
-          only-current-branch: 'true'
-          fail-on-download: 'true'
-      - name: "Load CI image linux/amd64:${{ 
needs.build-info.outputs.default-python-version }}"
-        env:
-          PYTHON: ${{ needs.build-info.outputs.default-python-version }}
-        run: >
-          breeze ci-image load --platform "linux/amd64" --python "${PYTHON}" 
--image-file-dir "/mnt"
+          python: "${{ needs.build-info.outputs.docs-python-version }}"
+          platform: "linux/amd64"
+          image-stash-ref: "${{ needs.build-info.outputs.image-stash-ref }}"
+          github-token: "${{ secrets.GITHUB_TOKEN }}"
+          constraints-github-repository: >-
+            ${{ secrets.CONSTRAINTS_GITHUB_REPOSITORY != '' &&
+            secrets.CONSTRAINTS_GITHUB_REPOSITORY || 'apache/airflow' }}
       - name: "Restore docs inventory cache"
         uses: 
apache/infrastructure-actions/stash/restore@0ff9972b5872e19c9f4555c9159c2fea4f794355
         with:
@@ -499,7 +490,14 @@ jobs:
           overwrite: 'true'
 
   publish-docs-to-s3:
-    needs: [build-docs, build-java-sdk-docs, build-info]
+    needs: [build-docs, build-java-sdk-docs, build-info, update-registry]
+    # `update-registry` is skipped for non-provider distributions, and a job 
whose dependency was
+    # skipped is skipped in turn, so its result is checked rather than implied 
- which means the
+    # two that are not conditional have to be asserted here as well.
+    if: >
+      !cancelled() && needs.build-docs.result == 'success' &&
+      needs.build-java-sdk-docs.result == 'success' &&
+      needs.update-registry.result != 'failure'
     name: "Publish documentation to S3"
     permissions:
       id-token: write
@@ -511,7 +509,7 @@ jobs:
       GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
       GITHUB_USERNAME: ${{ github.actor }}
       INCLUDE_SUCCESS_OUTPUTS: false
-      PYTHON_MAJOR_MINOR_VERSION: "${{ 
needs.build-info.outputs.default-python-version }}"
+      PYTHON_MAJOR_MINOR_VERSION: "${{ 
needs.build-info.outputs.docs-python-version }}"
       VERBOSE: "true"
     steps:
       - name: "Cleanup repo"
@@ -553,7 +551,7 @@ jobs:
         env:
           AIRFLOW_VERSION: ${{ needs.build-info.outputs.airflow-version }}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          PYTHON_VERSION: "${{ needs.build-info.outputs.default-python-version 
}}"
+          PYTHON_VERSION: "${{ needs.build-info.outputs.docs-python-version }}"
           FORCE: "true"
         run: >
           breeze sbom update-sbom-information
@@ -643,8 +641,9 @@ jobs:
 
   update-registry:
     # Runs alongside the docs build rather than after it: the registry reads 
nothing the docs
-    # publish produces, so waiting only added its whole duration to the 
release manager's wait.
-    needs: [build-info, build-ci-image]
+    # build produces, so ordering the two only added its whole duration to the 
wait. The publish
+    # does wait for it, so a run reports success once both halves are actually 
out.
+    needs: [build-info]
     if: needs.build-info.outputs.registry-providers != '' || 
needs.build-info.outputs.registry-full-build == 'true'
     name: "Update Provider Registry"
     permissions:
@@ -654,10 +653,12 @@ jobs:
     with:
       destination: ${{ needs.build-info.outputs.destination }}
       provider: ${{ needs.build-info.outputs.registry-providers }}
-      python-version: ${{ needs.build-info.outputs.default-python-version }}
-      # `build-ci-image` already built and stashed the image this run - under 
the ref's key.
-      ci-image-already-built: true
-      image-stash-ref: ${{ inputs.ref }}
+      # The registry extracts on 3.12 and the docs build on the default 
Python, so each builds the
+      # image it needs in the job that uses it, keyed by this ref so 
consecutive publishes reuse it.
+      python-version: ${{ needs.build-info.outputs.registry-python-version }}
+      # Without this the registry would read main rather than the ref being 
published.
+      checkout-ref: ${{ inputs.ref }}
+      image-stash-ref: ${{ needs.build-info.outputs.image-stash-ref }}
     secrets:
       DOCS_AWS_ACCESS_KEY_ID: ${{ secrets.DOCS_AWS_ACCESS_KEY_ID }}
       DOCS_AWS_SECRET_ACCESS_KEY: ${{ secrets.DOCS_AWS_SECRET_ACCESS_KEY }}
diff --git a/.github/workflows/registry-build.yml 
b/.github/workflows/registry-build.yml
index c5bae276172..da239882629 100644
--- a/.github/workflows/registry-build.yml
+++ b/.github/workflows/registry-build.yml
@@ -55,11 +55,13 @@ on:  # yamllint disable-line rule:truthy
         required: false
         type: string
         default: "3.12"
-      ci-image-already-built:
-        description: "Caller has already built and stashed the CI image for 
this run"
+      checkout-ref:
+        description: >
+          Ref the registry is built from. Defaults to the branch tip; a 
release publish passes the
+          tag it is publishing so the extraction reads those sources rather 
than main's.
         required: false
-        type: boolean
-        default: false
+        type: string
+        default: ""
       # `github.event_name` inside a called workflow reports the *caller's* 
event, never
       # `workflow_call`, so it cannot distinguish the two entry points. An 
input declared
       # only here can: it defaults to true when called and is undefined 
(falsy) on a
@@ -88,57 +90,13 @@ permissions:
   packages: read
 
 jobs:
-  build-ci-image:
-    name: "Build CI image"
-    uses: ./.github/workflows/ci-image-build.yml
-    permissions:
-      contents: read
-      packages: write
-    # Skipped when the caller stashed an image for this run; a standalone 
dispatch has no
-    # such image and still builds its own.
-    if: >
-      (inputs.ci-image-already-built != true) && (
-      inputs.is-workflow-call ||
-      contains(fromJSON('[
-        "ashb",
-        "bugraoz93",
-        "eladkal",
-        "ephraimbuddy",
-        "jedcunningham",
-        "jscheffl",
-        "kaxil",
-        "pierrejeambrun",
-        "shahar1",
-        "potiuk",
-        "uranusjr",
-        "utkarsharma2",
-        "vincbeck"
-        ]'), github.event.sender.login))
-    with:
-      runners: '["ubuntu-22.04"]'
-      platform: "linux/amd64"
-      push-image: "false"
-      upload-image-artifact: "true"
-      # Kept so a standalone registry dispatch reuses the mount cache its last 
run left behind.
-      upload-mount-cache-artifact: "true"
-      python-versions: ${{ format('["{0}"]', inputs.python-version) }}
-      branch: "main"
-      constraints-branch: "constraints-main"
-      use-uv: "true"
-      upgrade-to-newer-dependencies: "false"
-      docker-cache: "registry"
-      disable-airflow-repo-cache: "false"
-
   build-and-publish-registry:
     timeout-minutes: 45
     name: "Build & Publish Registry"
-    needs: [build-ci-image]
-    # `build-ci-image` is skipped when the caller stashed the image, so this 
cannot simply
-    # inherit its result. On a dispatch it enforces the committer allowlist 
below; on the
-    # `workflow_call` path the caller has already gated on its own allowlist, 
so this must
-    # not re-check it against a second, separately-maintained copy.
+    # On a dispatch this enforces the committer allowlist below; on the 
`workflow_call` path the
+    # caller has already gated on its own allowlist, so this must not re-check 
it against a
+    # second, separately-maintained copy.
     if: >
-      !cancelled() && needs.build-ci-image.result != 'failure' && (
       inputs.is-workflow-call ||
       contains(fromJSON('[
         "ashb",
@@ -154,7 +112,7 @@ jobs:
         "uranusjr",
         "utkarsharma2",
         "vincbeck"
-        ]'), github.event.sender.login))
+        ]'), github.event.sender.login)
     runs-on: ubuntu-latest
     env:
       SCARF_ANALYTICS: "false"
@@ -169,25 +127,51 @@ jobs:
       REGISTRY_CACHE_CONTROL: public, max-age=300
     permissions:
       contents: read
+      # The image is built here now, so this job reads the ghcr registry build 
cache itself.
+      packages: read
     steps:
       - name: "Checkout repository"
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
         with:
           persist-credentials: false
+          # Empty means the branch tip; a release publish passes the tag it is 
publishing, so the
+          # extraction reads the sources being released rather than whatever 
main holds.
+          ref: ${{ inputs.checkout-ref }}
           # Tags drive the phantom-version filter in extract_metadata.py
           # (only versions with a real `providers-<id>/<ver>` tag are
           # treated as released). Without this, the filter silently
           # falls back to `versions[0]` and ships phantom versions.
           fetch-tags: true
 
-      - name: "Prepare breeze & CI image"
-        uses: ./.github/actions/prepare_breeze_and_image
+      # A local action is read from the workspace, not from the ref the 
workflow itself runs from,
+      # so an action added after the checked-out ref was cut is simply absent. 
Checking the
+      # workflow's own version out alongside supplies the definition; the 
image is still built
+      # from the sources checked out above.
+      - name: "Checkout the workflow version for its actions"
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
+        with:
+          persist-credentials: false
+          path: workflow-version
+
+      - name: "Make /mnt writeable and cleanup"
+        run: ./scripts/ci/make_mnt_writeable.sh
+      - name: "Free up disk space"
+        run: ./scripts/tools/free_up_disk_space.sh
+      - name: "Install Breeze"
+        uses: ./.github/actions/breeze
+        with:
+          python-version: "${{ inputs.python-version }}"
+
+      # Built in this job rather than handed over by one of its own: the image 
has a single
+      # consumer, so a separate job could only export it and import it 
straight back. It is still
+      # stashed under this ref, so the next run for the same ref seeds its 
build from it.
+      - name: "Build CI image linux/amd64:${{ inputs.python-version }}"
+        uses: ./workflow-version/.github/actions/build_ci_image_with_cache
         with:
           python: "${{ inputs.python-version }}"
           platform: "linux/amd64"
-          use-uv: "true"
-          make-mnt-writeable-and-cleanup: "true"
           image-stash-ref: "${{ inputs.image-stash-ref }}"
+          github-token: "${{ secrets.GITHUB_TOKEN }}"
 
       - name: "Install AWS CLI v2"
         run: |
diff --git a/dev/breeze/doc/images/output_build-docs.svg 
b/dev/breeze/doc/images/output_build-docs.svg
index 78ab4748143..99bc5dbc0de 100644
--- a/dev/breeze/doc/images/output_build-docs.svg
+++ b/dev/breeze/doc/images/output_build-docs.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 1482 1587.1999999999998" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 1482 1538.3999999999999" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -43,7 +43,7 @@
 
     <defs>
     <clipPath id="breeze-build-docs-clip-terminal">
-      <rect x="0" y="0" width="1463.0" height="1536.1999999999998" />
+      <rect x="0" y="0" width="1463.0" height="1487.3999999999999" />
     </clipPath>
     <clipPath id="breeze-build-docs-line-0">
     <rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -225,15 +225,9 @@
 <clipPath id="breeze-build-docs-line-59">
     <rect x="0" y="1441.1" width="1464" height="24.65"/>
             </clipPath>
-<clipPath id="breeze-build-docs-line-60">
-    <rect x="0" y="1465.5" width="1464" height="24.65"/>
-            </clipPath>
-<clipPath id="breeze-build-docs-line-61">
-    <rect x="0" y="1489.9" width="1464" height="24.65"/>
-            </clipPath>
     </defs>
 
-    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="1585.2" rx="8"/><text 
class="breeze-build-docs-title" fill="#c5c8c6" text-anchor="middle" x="740" 
y="27">Command:&#160;build-docs</text>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="1536.4" rx="8"/><text 
class="breeze-build-docs-title" fill="#c5c8c6" text-anchor="middle" x="740" 
y="27">Command:&#160;build-docs</text>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
@@ -289,23 +283,21 @@
 </text><text class="breeze-build-docs-r5" x="0" y="1069.2" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-43)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1069.2" textLength="353.8" 
clip-path="url(#breeze-build-docs-line-43)">--include-removed-providers&#160;&#160;</text><text
 class="breeze-build-docs-r1" x="402.6" y="1069.2" textLength="561.2" 
clip-path="url(#breeze-build-docs-line-43)">Whether&#160;to&#160;include&#160;providers&#160;that&#160;are&#160;removed.</te
 [...]
 </text><text class="breeze-build-docs-r5" x="0" y="1093.6" textLength="1464" 
clip-path="url(#breeze-build-docs-line-44)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-build-docs-r1" x="1464" y="1093.6" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-44)">
 </text><text class="breeze-build-docs-r5" x="0" y="1118" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-45)">╭─</text><text 
class="breeze-build-docs-r5" x="24.4" y="1118" textLength="170.8" 
clip-path="url(#breeze-build-docs-line-45)">&#160;Misc&#160;options&#160;</text><text
 class="breeze-build-docs-r5" x="195.2" y="1118" textLength="1244.4" 
clip-path="url(#breeze-build-docs-line-45)">──────────────────────────────────────────────────────────────────────────────────────────────
 [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1142.4" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-46)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1142.4" textLength="244" 
clip-path="url(#breeze-build-docs-line-46)">--python&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-build-docs-r6" x="292.8" y="1142.4" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-46)">-p</text><text 
class="breeze-build-docs-r1 [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1166.8" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-47)">│</text><text 
class="breeze-build-docs-r7" x="341.6" y="1166.8" textLength="317.2" 
clip-path="url(#breeze-build-docs-line-47)">3.11&#160;|&#160;3.12&#160;|&#160;3.13&#160;|&#160;3.14)</text><text
 class="breeze-build-docs-r5" x="1451.8" y="1166.8" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-47)">│</text><text 
class="breeze-build-docs-r1" x="1464" y="1166.8" [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1191.2" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-48)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1191.2" textLength="244" 
clip-path="url(#breeze-build-docs-line-48)">--include-commits&#160;&#160;&#160;</text><text
 class="breeze-build-docs-r1" x="341.6" y="1191.2" textLength="451.4" 
clip-path="url(#breeze-build-docs-line-48)">Include&#160;commits&#160;in&#160;the&#160;documentation.</text><text
 class="breeze-bu [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1215.6" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-49)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1215.6" textLength="244" 
clip-path="url(#breeze-build-docs-line-49)">--github-repository&#160;</text><text
 class="breeze-build-docs-r6" x="292.8" y="1215.6" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-49)">-g</text><text 
class="breeze-build-docs-r1" x="341.6" y="1215.6" textLength="597.8" 
clip-path="ur [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1240" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-50)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1240" textLength="244" 
clip-path="url(#breeze-build-docs-line-50)">--builder&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-build-docs-r1" x="341.6" y="1240" textLength="768.6" 
clip-path="url(#breeze-build-docs-line-50)">Buildx&#160;builder&#160;used&#160;to&#160;perform&#
 [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1264.4" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-51)">│</text><text 
class="breeze-build-docs-r7" x="341.6" y="1264.4" textLength="73.2" 
clip-path="url(#breeze-build-docs-line-51)">(TEXT)</text><text 
class="breeze-build-docs-r5" x="1451.8" y="1264.4" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-51)">│</text><text 
class="breeze-build-docs-r1" x="1464" y="1264.4" textLength="12.2" 
clip-path="url(#breeze-build-doc [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1288.8" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-52)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1288.8" textLength="244" 
clip-path="url(#breeze-build-docs-line-52)">--distributions-list</text><text 
class="breeze-build-docs-r1" x="341.6" y="1288.8" textLength="1098" 
clip-path="url(#breeze-build-docs-line-52)">Optional,&#160;contains&#160;space&#160;separated&#160;list&#160;of&#160;package&#160;ids&#160;that&#1
 [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1313.2" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-53)">│</text><text 
class="breeze-build-docs-r1" x="341.6" y="1313.2" textLength="1098" 
clip-path="url(#breeze-build-docs-line-53)">documentation&#160;building,&#160;and&#160;document&#160;publishing.&#160;It&#160;is&#160;an&#160;easier&#160;alternative&#160;to&#160;adding&#160;&#160;&#160;&#160;</text><text
 class="breeze-build-docs-r5" x="1451.8" y="1313.2" textLength="12.2 [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1337.6" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-54)">│</text><text 
class="breeze-build-docs-r1" x="341.6" y="1337.6" textLength="1098" 
clip-path="url(#breeze-build-docs-line-54)">individual&#160;packages&#160;as&#160;arguments&#160;to&#160;every&#160;command.&#160;This&#160;overrides&#160;the&#160;packages&#160;passed&#160;as&#160;&#160;</text><text
 class="breeze-build-docs-r5" x="1451.8" y="1337.6" textLength="12.2" cli [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1362" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-55)">│</text><text 
class="breeze-build-docs-r1" x="341.6" y="1362" textLength="134.2" 
clip-path="url(#breeze-build-docs-line-55)">arguments.&#160;</text><text 
class="breeze-build-docs-r7" x="475.8" y="1362" textLength="73.2" 
clip-path="url(#breeze-build-docs-line-55)">(TEXT)</text><text 
class="breeze-build-docs-r5" x="1451.8" y="1362" textLength="12.2" 
clip-path="url(#breeze- [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1386.4" textLength="1464" 
clip-path="url(#breeze-build-docs-line-56)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-build-docs-r1" x="1464" y="1386.4" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-56)">
-</text><text class="breeze-build-docs-r5" x="0" y="1410.8" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-57)">╭─</text><text 
class="breeze-build-docs-r5" x="24.4" y="1410.8" textLength="195.2" 
clip-path="url(#breeze-build-docs-line-57)">&#160;Common&#160;options&#160;</text><text
 class="breeze-build-docs-r5" x="219.6" y="1410.8" textLength="1220" 
clip-path="url(#breeze-build-docs-line-57)">────────────────────────────────────────────────────────────────────────────────────────
 [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1435.2" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-58)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1435.2" textLength="109.8" 
clip-path="url(#breeze-build-docs-line-58)">--dry-run</text><text 
class="breeze-build-docs-r6" x="158.6" y="1435.2" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-58)">-D</text><text 
class="breeze-build-docs-r1" x="207.4" y="1435.2" textLength="719.8" 
clip-path="url(#breeze-buil [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1459.6" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-59)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1459.6" textLength="109.8" 
clip-path="url(#breeze-build-docs-line-59)">--verbose</text><text 
class="breeze-build-docs-r6" x="158.6" y="1459.6" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-59)">-v</text><text 
class="breeze-build-docs-r1" x="207.4" y="1459.6" textLength="585.6" 
clip-path="url(#breeze-buil [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1484" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-60)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1484" textLength="109.8" 
clip-path="url(#breeze-build-docs-line-60)">--answer&#160;</text><text 
class="breeze-build-docs-r6" x="158.6" y="1484" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-60)">-a</text><text 
class="breeze-build-docs-r1" x="207.4" y="1484" textLength="329.4" 
clip-path="url(#breeze-build-d [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1508.4" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-61)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1508.4" textLength="109.8" 
clip-path="url(#breeze-build-docs-line-61)">--help&#160;&#160;&#160;</text><text
 class="breeze-build-docs-r6" x="158.6" y="1508.4" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-61)">-h</text><text 
class="breeze-build-docs-r1" x="207.4" y="1508.4" textLength="329.4" 
clip-path="u [...]
-</text><text class="breeze-build-docs-r5" x="0" y="1532.8" textLength="1464" 
clip-path="url(#breeze-build-docs-line-62)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-build-docs-r1" x="1464" y="1532.8" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-62)">
+</text><text class="breeze-build-docs-r5" x="0" y="1142.4" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-46)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1142.4" textLength="244" 
clip-path="url(#breeze-build-docs-line-46)">--include-commits&#160;&#160;&#160;</text><text
 class="breeze-build-docs-r1" x="341.6" y="1142.4" textLength="451.4" 
clip-path="url(#breeze-build-docs-line-46)">Include&#160;commits&#160;in&#160;the&#160;documentation.</text><text
 class="breeze-bu [...]
+</text><text class="breeze-build-docs-r5" x="0" y="1166.8" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-47)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1166.8" textLength="244" 
clip-path="url(#breeze-build-docs-line-47)">--github-repository&#160;</text><text
 class="breeze-build-docs-r6" x="292.8" y="1166.8" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-47)">-g</text><text 
class="breeze-build-docs-r1" x="341.6" y="1166.8" textLength="597.8" 
clip-path="ur [...]
+</text><text class="breeze-build-docs-r5" x="0" y="1191.2" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-48)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1191.2" textLength="244" 
clip-path="url(#breeze-build-docs-line-48)">--builder&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-build-docs-r1" x="341.6" y="1191.2" textLength="768.6" 
clip-path="url(#breeze-build-docs-line-48)">Buildx&#160;builder&#160;used&#160;to&#160;per
 [...]
+</text><text class="breeze-build-docs-r5" x="0" y="1215.6" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-49)">│</text><text 
class="breeze-build-docs-r7" x="341.6" y="1215.6" textLength="73.2" 
clip-path="url(#breeze-build-docs-line-49)">(TEXT)</text><text 
class="breeze-build-docs-r5" x="1451.8" y="1215.6" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-49)">│</text><text 
class="breeze-build-docs-r1" x="1464" y="1215.6" textLength="12.2" 
clip-path="url(#breeze-build-doc [...]
+</text><text class="breeze-build-docs-r5" x="0" y="1240" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-50)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1240" textLength="244" 
clip-path="url(#breeze-build-docs-line-50)">--distributions-list</text><text 
class="breeze-build-docs-r1" x="341.6" y="1240" textLength="1098" 
clip-path="url(#breeze-build-docs-line-50)">Optional,&#160;contains&#160;space&#160;separated&#160;list&#160;of&#160;package&#160;ids&#160;that&#160;are
 [...]
+</text><text class="breeze-build-docs-r5" x="0" y="1264.4" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-51)">│</text><text 
class="breeze-build-docs-r1" x="341.6" y="1264.4" textLength="1098" 
clip-path="url(#breeze-build-docs-line-51)">documentation&#160;building,&#160;and&#160;document&#160;publishing.&#160;It&#160;is&#160;an&#160;easier&#160;alternative&#160;to&#160;adding&#160;&#160;&#160;&#160;</text><text
 class="breeze-build-docs-r5" x="1451.8" y="1264.4" textLength="12.2 [...]
+</text><text class="breeze-build-docs-r5" x="0" y="1288.8" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-52)">│</text><text 
class="breeze-build-docs-r1" x="341.6" y="1288.8" textLength="1098" 
clip-path="url(#breeze-build-docs-line-52)">individual&#160;packages&#160;as&#160;arguments&#160;to&#160;every&#160;command.&#160;This&#160;overrides&#160;the&#160;packages&#160;passed&#160;as&#160;&#160;</text><text
 class="breeze-build-docs-r5" x="1451.8" y="1288.8" textLength="12.2" cli [...]
+</text><text class="breeze-build-docs-r5" x="0" y="1313.2" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-53)">│</text><text 
class="breeze-build-docs-r1" x="341.6" y="1313.2" textLength="134.2" 
clip-path="url(#breeze-build-docs-line-53)">arguments.&#160;</text><text 
class="breeze-build-docs-r7" x="475.8" y="1313.2" textLength="73.2" 
clip-path="url(#breeze-build-docs-line-53)">(TEXT)</text><text 
class="breeze-build-docs-r5" x="1451.8" y="1313.2" textLength="12.2" 
clip-path="url( [...]
+</text><text class="breeze-build-docs-r5" x="0" y="1337.6" textLength="1464" 
clip-path="url(#breeze-build-docs-line-54)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-build-docs-r1" x="1464" y="1337.6" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-54)">
+</text><text class="breeze-build-docs-r5" x="0" y="1362" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-55)">╭─</text><text 
class="breeze-build-docs-r5" x="24.4" y="1362" textLength="195.2" 
clip-path="url(#breeze-build-docs-line-55)">&#160;Common&#160;options&#160;</text><text
 class="breeze-build-docs-r5" x="219.6" y="1362" textLength="1220" 
clip-path="url(#breeze-build-docs-line-55)">──────────────────────────────────────────────────────────────────────────────────────────────
 [...]
+</text><text class="breeze-build-docs-r5" x="0" y="1386.4" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-56)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1386.4" textLength="109.8" 
clip-path="url(#breeze-build-docs-line-56)">--dry-run</text><text 
class="breeze-build-docs-r6" x="158.6" y="1386.4" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-56)">-D</text><text 
class="breeze-build-docs-r1" x="207.4" y="1386.4" textLength="719.8" 
clip-path="url(#breeze-buil [...]
+</text><text class="breeze-build-docs-r5" x="0" y="1410.8" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-57)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1410.8" textLength="109.8" 
clip-path="url(#breeze-build-docs-line-57)">--verbose</text><text 
class="breeze-build-docs-r6" x="158.6" y="1410.8" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-57)">-v</text><text 
class="breeze-build-docs-r1" x="207.4" y="1410.8" textLength="585.6" 
clip-path="url(#breeze-buil [...]
+</text><text class="breeze-build-docs-r5" x="0" y="1435.2" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-58)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1435.2" textLength="109.8" 
clip-path="url(#breeze-build-docs-line-58)">--answer&#160;</text><text 
class="breeze-build-docs-r6" x="158.6" y="1435.2" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-58)">-a</text><text 
class="breeze-build-docs-r1" x="207.4" y="1435.2" textLength="329.4" 
clip-path="url(#breeze [...]
+</text><text class="breeze-build-docs-r5" x="0" y="1459.6" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-59)">│</text><text 
class="breeze-build-docs-r4" x="24.4" y="1459.6" textLength="109.8" 
clip-path="url(#breeze-build-docs-line-59)">--help&#160;&#160;&#160;</text><text
 class="breeze-build-docs-r6" x="158.6" y="1459.6" textLength="24.4" 
clip-path="url(#breeze-build-docs-line-59)">-h</text><text 
class="breeze-build-docs-r1" x="207.4" y="1459.6" textLength="329.4" 
clip-path="u [...]
+</text><text class="breeze-build-docs-r5" x="0" y="1484" textLength="1464" 
clip-path="url(#breeze-build-docs-line-60)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-build-docs-r1" x="1464" y="1484" textLength="12.2" 
clip-path="url(#breeze-build-docs-line-60)">
 </text>
     </g>
     </g>
diff --git a/dev/breeze/doc/images/output_build-docs.txt 
b/dev/breeze/doc/images/output_build-docs.txt
index cc79708d4cd..75dd9355bcf 100644
--- a/dev/breeze/doc/images/output_build-docs.txt
+++ b/dev/breeze/doc/images/output_build-docs.txt
@@ -1 +1 @@
-c6881341938aadc0176ded803153998d
+e22969fb5e92a3efafb744881fea5ef8
diff --git a/dev/breeze/src/airflow_breeze/commands/developer_commands.py 
b/dev/breeze/src/airflow_breeze/commands/developer_commands.py
index 0233a6919b0..8a41b104136 100644
--- a/dev/breeze/src/airflow_breeze/commands/developer_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/developer_commands.py
@@ -814,7 +814,6 @@ def _build_python_docs(
     *,
     generated_path: Path,
     builder: str,
-    python: str,
     clean_build: bool,
     clean_inventory_cache: bool,
     refresh_airflow_inventories: bool,
@@ -830,9 +829,14 @@ def _build_python_docs(
     spellcheck_only: bool,
     doc_packages: tuple[str, ...],
 ):
+    # Docs are always built on the default Python. The Sphinx configuration 
mocks third-party
+    # modules, and what that mocking does depends on the interpreter - on 3.12 
functools copies
+    # __type_params__, for which a mock returns another mock rather than a 
tuple, so providers
+    # decorating methods with functools.wraps over a mocked callable fail to 
import. Letting a
+    # caller pick the interpreter here silently changes what the docs build 
can document.
     build_params = BuildCiParams(
         github_repository=github_repository,
-        python=python,
+        python=DEFAULT_PYTHON_MAJOR_MINOR_VERSION,
         builder=builder,
     )
     rebuild_or_pull_ci_image_if_needed(command_params=build_params)
@@ -887,7 +891,7 @@ def _build_python_docs(
     )
     shell_params = ShellParams(
         github_repository=github_repository,
-        python=python,
+        python=DEFAULT_PYTHON_MAJOR_MINOR_VERSION,
         mount_sources=MOUNT_ALL,
     )
     result = execute_command_in_shell(shell_params, 
project_name="breeze-docs", command=cmd)
@@ -934,7 +938,6 @@ def _build_python_docs(
 @option_github_repository
 @option_include_not_ready_providers
 @option_include_removed_providers
-@option_python
 @click.option(
     "--one-pass-only",
     help="Builds documentation in one pass only. This is useful for debugging 
sphinx errors.",
@@ -979,7 +982,6 @@ def build_docs(
     include_commits: bool,
     one_pass_only: bool,
     package_filter: tuple[str, ...],
-    python: str,
     distributions_list: str,
     spellcheck_only: bool,
     sdk: tuple[str, ...],
@@ -1010,7 +1012,6 @@ def build_docs(
             include_commits=include_commits,
             one_pass_only=one_pass_only,
             package_filter=package_filter,
-            python=python,
             distributions_list=distributions_list,
             spellcheck_only=spellcheck_only,
             doc_packages=doc_packages,
diff --git 
a/dev/breeze/src/airflow_breeze/commands/developer_commands_config.py 
b/dev/breeze/src/airflow_breeze/commands/developer_commands_config.py
index f3816139abe..17d32146110 100644
--- a/dev/breeze/src/airflow_breeze/commands/developer_commands_config.py
+++ b/dev/breeze/src/airflow_breeze/commands/developer_commands_config.py
@@ -385,7 +385,6 @@ DEVELOPER_PARAMETERS: dict[str, list[dict[str, str | 
list[str]]]] = {
         {
             "name": "Misc options",
             "options": [
-                "--python",
                 "--include-commits",
                 "--github-repository",
                 "--builder",
diff --git a/dev/breeze/tests/test_developer_commands.py 
b/dev/breeze/tests/test_developer_commands.py
index d5b0ca1a070..2336b0585ba 100644
--- a/dev/breeze/tests/test_developer_commands.py
+++ b/dev/breeze/tests/test_developer_commands.py
@@ -23,6 +23,7 @@ import pytest
 from click.testing import CliRunner
 
 from airflow_breeze.commands.developer_commands import build_docs
+from airflow_breeze.global_constants import DEFAULT_PYTHON_MAJOR_MINOR_VERSION
 
 
 @pytest.fixture
@@ -31,7 +32,12 @@ def runner():
 
 
 class TestBuildDocsPythonVersion:
-    """`breeze build-docs` must document with the Python version it was asked 
for."""
+    """`breeze build-docs` always documents on the default Python.
+
+    The Sphinx config mocks third-party modules and what that mocking does 
depends on the
+    interpreter, so the docs build must not follow whatever Python the caller 
happens to have
+    selected - see the comment in ``_build_python_docs``.
+    """
 
     @pytest.fixture(autouse=True)
     def _no_docker(self, monkeypatch):
@@ -54,15 +60,18 @@ class TestBuildDocsPythonVersion:
             runner.invoke(build_docs, args, env=env, catch_exceptions=False)
         return mock_rebuild, mock_shell
 
-    @pytest.mark.parametrize(
-        ("args", "env"),
-        [
-            pytest.param(["--python", "3.12"], None, id="python-option"),
-            pytest.param([], {"PYTHON_MAJOR_MINOR_VERSION": "3.12"}, 
id="python-env-var"),
-        ],
-    )
-    def test_selected_python_is_used_for_image_and_shell(self, runner, args, 
env):
-        mock_rebuild, mock_shell = self._invoke(runner, [*args, 
"--docs-only"], env=env)
+    def test_environment_python_does_not_change_the_docs_build(self, runner):
+        # PYTHON_MAJOR_MINOR_VERSION is set on every job of the docs 
publishing workflow, so an
+        # option reading it silently decided what the docs were built with.
+        mock_rebuild, mock_shell = self._invoke(
+            runner, ["--docs-only"], env={"PYTHON_MAJOR_MINOR_VERSION": "3.12"}
+        )
+
+        assert mock_rebuild.call_args.kwargs["command_params"].python == 
DEFAULT_PYTHON_MAJOR_MINOR_VERSION
+        assert mock_shell.call_args.args[0].python == 
DEFAULT_PYTHON_MAJOR_MINOR_VERSION
+
+    def test_python_option_is_rejected(self, runner):
+        result = runner.invoke(build_docs, ["--python", "3.12", "--docs-only"])
 
-        assert mock_rebuild.call_args.kwargs["command_params"].python == "3.12"
-        assert mock_shell.call_args.args[0].python == "3.12"
+        assert result.exit_code != 0
+        assert "no such option" in result.output.lower()

Reply via email to