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

potiuk pushed a commit to branch pin-os-deps-image-layer
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit 008485dc8bfe6e6aa8f3e89bbdc98126eb4342ce
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun Nov 1 10:40:34 2026 +0100

    Build the OS-dependencies images daily instead of on every mirror run
    
    Docker rebuilds the hardened base images several times a day, and 
rebuilding every OS-dependencies image for each new digest would run a full set 
of image builds on every mirror run. Building them daily is enough to keep the 
pinned images close to the apt repository, and the upgrade check no longer 
triggers builds of its own. As the base image pins now move more often than the 
OS-dependencies images, the upgrade check pins the latest published image of 
each Python patch release, whate [...]
    
    Generated-by: Claude Opus 5
---
 .github/workflows/mirror-hardened-base-images.yml  | 33 ++++++++++++++++------
 .github/workflows/upgrade-check.yml                |  8 +++---
 dev/breeze/doc/09_release_management_tasks.rst     | 12 +++++---
 .../output_workflow-run_mirror-base-images.svg     | 30 +++++++++++++++-----
 .../output_workflow-run_mirror-base-images.txt     |  2 +-
 .../src/airflow_breeze/commands/ci_commands.py     |  1 +
 .../commands/release_management_commands.py        | 22 +++++++--------
 .../airflow_breeze/commands/workflow_commands.py   | 15 ++++++++--
 .../commands/workflow_commands_config.py           |  1 +
 dev/breeze/src/airflow_breeze/global_constants.py  |  6 ++--
 .../src/airflow_breeze/utils/gh_workflow_utils.py  |  8 ++++--
 dev/breeze/tests/test_ci_upgrade.py                |  1 +
 dev/breeze/tests/test_gh_workflow_utils.py         |  6 ++--
 dev/breeze/tests/test_os_deps_images.py            | 11 ++++----
 dev/breeze/tests/test_workflow_commands.py         | 19 +++++++++----
 docker-stack-docs/build.rst                        |  4 +--
 scripts/ci/prek/upgrade_important_versions.py      | 24 ++++++++--------
 .../ci/prek/test_upgrade_important_versions.py     | 13 ++++++---
 18 files changed, 143 insertions(+), 73 deletions(-)

diff --git a/.github/workflows/mirror-hardened-base-images.yml 
b/.github/workflows/mirror-hardened-base-images.yml
index c66a397e417..e689c741cea 100644
--- a/.github/workflows/mirror-hardened-base-images.yml
+++ b/.github/workflows/mirror-hardened-base-images.yml
@@ -31,13 +31,16 @@ name: Mirror hardened base images
 # The builds do not use these moving tags: they are pinned to digests that 
only "breeze ci upgrade"
 # moves, in a PR that has to pass the full CI. A pinned image cannot pin the 
dhi.io apt repository
 # the OS dependencies are installed from, though: it moves on - and drops the 
versions it replaced -
-# whenever Docker rebuilds its images. So this workflow also installs the OS 
dependencies once, in
+# whenever Docker rebuilds its images. So the daily run also installs the OS 
dependencies once, in
 # images built on the mirrored base images, and the CI and release builds 
start from those, pinned
-# the same way. An OS-dependencies image is built only when one of its inputs 
changed - the base
-# image, install_os_dependencies.sh or its Dockerfile stage - as every input 
is part of its tag.
+# the same way. The six-hourly runs only mirror. An OS-dependencies image is 
built only when one of
+# its inputs changed - the base image, install_os_dependencies.sh or its 
Dockerfile stage - as every
+# input is part of its tag.
 #
 # A run that moves a mirrored tag past its pin, or publishes a new 
OS-dependencies image, triggers
-# the upgrade check, which moves both pins together, so they follow Docker's 
rebuilds within hours.
+# the upgrade check. It pins the latest published OS-dependencies image of 
each Python patch release,
+# whatever base image digest it was built on: the image carries its own 
Python, so it does not have
+# to share the base image pin's digest.
 on:  # yamllint disable-line rule:truthy
   workflow_dispatch:
     inputs:
@@ -47,8 +50,16 @@ on:  # yamllint disable-line rule:truthy
           published. "breeze ci upgrade" for main turns it off, as it is the 
upgrade that pins the images.
         type: boolean
         default: true
+      build-os-deps-images:
+        description: >-
+          Also build and publish the OS-dependencies images. The upgrade check 
turns it off: it pins the
+          images the daily build published.
+        type: boolean
+        default: true
   schedule:
     - cron: '27 */6 * * *'
+    # The daily run also builds the OS-dependencies images - the jobs below 
match this exact string.
+    - cron: '47 2 * * *'
 permissions:
   contents: read
 concurrency:
@@ -146,16 +157,18 @@ jobs:
       - name: "Mirror base images"
         id: mirror
         run: breeze release-management mirror-base-images
-  # Each platform builds natively, on a runner of its own, and each Python 
version in a job of its
-  # own. The OS-dependencies images are built on the tags the mirror serves 
now - the digests the
-  # upgrade check pins next - and only for the Python versions and the Debian 
version main builds on.
+  # Daily, or when dispatched with build-os-deps-images. Each platform builds 
natively, on a runner of
+  # its own, and each Python version in a job of its own. The OS-dependencies 
images are built on the
+  # tags the mirror serves now, and only for the Python versions and the 
Debian version main builds on.
   build-os-deps-images:
     name: "Build OS-dependencies images ${{ matrix.platform }}:${{ 
matrix.python }}"
     needs: [build-info, mirror]
     # Also after a failed mirror: the tags it did mirror are served, and the 
builds look them up.
     if: >-
       !cancelled() && needs.build-info.result == 'success' &&
-      github.repository == 'apache/airflow' && github.ref == 'refs/heads/main'
+      github.repository == 'apache/airflow' && github.ref == 'refs/heads/main' 
&&
+      ((github.event_name == 'schedule' && github.event.schedule == '47 2 * * 
*') ||
+      (github.event_name == 'workflow_dispatch' && 
inputs.build-os-deps-images))
     strategy:
       fail-fast: false
       matrix:
@@ -197,7 +210,9 @@ jobs:
     # rest.
     if: >-
       !cancelled() && needs.build-info.result == 'success' &&
-      github.repository == 'apache/airflow' && github.ref == 'refs/heads/main'
+      github.repository == 'apache/airflow' && github.ref == 'refs/heads/main' 
&&
+      ((github.event_name == 'schedule' && github.event.schedule == '47 2 * * 
*') ||
+      (github.event_name == 'workflow_dispatch' && 
inputs.build-os-deps-images))
     runs-on: ${{ format(fromJSON(needs.build-info.outputs.runner-type)[0], 
'2+4', '8+16') }}
     timeout-minutes: 30
     permissions:
diff --git a/.github/workflows/upgrade-check.yml 
b/.github/workflows/upgrade-check.yml
index bab6d24310d..04344a3c6e9 100644
--- a/.github/workflows/upgrade-check.yml
+++ b/.github/workflows/upgrade-check.yml
@@ -77,8 +77,7 @@ jobs:
   # A job of its own, so the token that may dispatch workflows never reaches 
the upgrade job, which
   # runs the third-party tools and hooks it upgrades. It only dispatches the 
mirror and waits for it.
   mirror-base-images:
-    # The mirror run also builds the OS-dependencies images on newly mirrored 
base images.
-    timeout-minutes: 180
+    timeout-minutes: 30
     name: >-
       [${{ inputs.target-branch }}] Mirror hardened base images
     runs-on: ["ubuntu-slim"]
@@ -97,13 +96,14 @@ jobs:
         uses: ./.github/actions/breeze
         with:
           free-space: "false"
-      # The upgrade for main is the one the mirror would otherwise start a 
second time.
+      # The upgrade for main is the one the mirror would otherwise start a 
second time. The upgrade pins
+      # the OS-dependencies images the daily build published, so this run does 
not build new ones.
       - name: >-
           [${{ inputs.target-branch }}] Mirror the base images and wait for 
the run
         env:
           TRIGGER_UPGRADE_CHECK_FLAG: >-
             ${{ inputs.target-branch == 'main' && '--no-trigger-upgrade-check' 
|| '--trigger-upgrade-check' }}
-        run: breeze workflow-run mirror-base-images 
"${TRIGGER_UPGRADE_CHECK_FLAG}"
+        run: breeze workflow-run mirror-base-images 
"${TRIGGER_UPGRADE_CHECK_FLAG}" --no-build-os-deps-images
   createupgrade-check:
     timeout-minutes: 45
     name: >-
diff --git a/dev/breeze/doc/09_release_management_tasks.rst 
b/dev/breeze/doc/09_release_management_tasks.rst
index a3dd4cf31cf..0596878978e 100644
--- a/dev/breeze/doc/09_release_management_tasks.rst
+++ b/dev/breeze/doc/09_release_management_tasks.rst
@@ -1353,8 +1353,9 @@ already ran, one per installation type (``ci``, ``dev`` 
and ``runtime``). They a
 mirror serves, and pinned in 
``dev/breeze/src/airflow_breeze/files/hardened_os_deps_image_digests.json``
 by the same upgrade that moves the base image pins.
 
-The "Mirror hardened base images" workflow runs this command natively on an 
AMD and an ARM runner after
-it mirrored the base images. Every input of an image - the base image digest, 
the script, its Dockerfile
+The "Mirror hardened base images" workflow runs this command daily, natively 
on an AMD and an ARM runner,
+after it mirrored the base images - its six-hourly runs only mirror, and the 
run the upgrade check
+dispatches (``breeze workflow-run mirror-base-images 
--no-build-os-deps-images``) builds nothing either. Every input of an image - 
the base image digest, the script, its Dockerfile
 stage and its build args - is part of its tag, so an image is built only when 
one of them changed.
 
 These are all available flags of ``release-management build-os-deps-images`` 
command:
@@ -1381,8 +1382,11 @@ Publish the OS-dependencies images
 """"""""""""""""""""""""""""""""""
 
 Once both platforms are built, this command merges them into a multi-platform 
image and moves the tag
-the upgrade check pins - the latest OS-dependencies image built on a given 
digest of the base image -
-to it. When it moves a tag, the workflow triggers the upgrade check.
+the upgrade check pins - the latest published OS-dependencies image of a 
Python patch release - to it.
+The merged image keeps its own tag, which has every input in it and never 
moves, so a pinned digest
+stays tagged. When it moves a tag, the workflow triggers the upgrade check, 
which pins the latest image
+of each Python patch release whatever base image digest it was built on: the 
image carries its own
+Python, so it need not share the digest of the base image pin, which moves 
several times a day.
 
 These are all available flags of ``release-management publish-os-deps-images`` 
command:
 
diff --git a/dev/breeze/doc/images/output_workflow-run_mirror-base-images.svg 
b/dev/breeze/doc/images/output_workflow-run_mirror-base-images.svg
index 9c61d5dd5f0..9562fa46d7b 100644
--- a/dev/breeze/doc/images/output_workflow-run_mirror-base-images.svg
+++ b/dev/breeze/doc/images/output_workflow-run_mirror-base-images.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 1482 367.2" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 1482 464.79999999999995" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -42,7 +42,7 @@
 
     <defs>
     <clipPath id="breeze-workflow-run-mirror-base-images-clip-terminal">
-      <rect x="0" y="0" width="1463.0" height="316.2" />
+      <rect x="0" y="0" width="1463.0" height="413.79999999999995" />
     </clipPath>
     <clipPath id="breeze-workflow-run-mirror-base-images-line-0">
     <rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -80,9 +80,21 @@
 <clipPath id="breeze-workflow-run-mirror-base-images-line-11">
     <rect x="0" y="269.9" width="1464" height="24.65"/>
             </clipPath>
+<clipPath id="breeze-workflow-run-mirror-base-images-line-12">
+    <rect x="0" y="294.3" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-workflow-run-mirror-base-images-line-13">
+    <rect x="0" y="318.7" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-workflow-run-mirror-base-images-line-14">
+    <rect x="0" y="343.1" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-workflow-run-mirror-base-images-line-15">
+    <rect x="0" y="367.5" 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="365.2" rx="8"/><text 
class="breeze-workflow-run-mirror-base-images-title" fill="#c5c8c6" 
text-anchor="middle" x="740" 
y="27">Command:&#160;workflow-run&#160;mirror-base-images</text>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="462.8" rx="8"/><text 
class="breeze-workflow-run-mirror-base-images-title" fill="#c5c8c6" 
text-anchor="middle" x="740" 
y="27">Command:&#160;workflow-run&#160;mirror-base-images</text>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
@@ -101,10 +113,14 @@
 </text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="166.4" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-6)">│</text><text 
class="breeze-workflow-run-mirror-base-images-r4" x="24.4" y="166.4" 
textLength="280.6" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-6)">--trigger-upgrade-check</text><text
 class="breeze-workflow-run-mirror-base-images-r1" x="305" y="166.4" 
textLength="12.2" clip-path="url(#breeze-workflow-run-mirror- [...]
 </text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="190.8" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-7)">│</text><text 
class="breeze-workflow-run-mirror-base-images-r1" x="658.8" y="190.8" 
textLength="780.8" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-7)">its&#160;pin.&#160;Turn&#160;it&#160;off&#160;when&#160;the&#160;main&#160;upgrade&#160;check&#160;is&#160;what&#160;runs&#160;&#160;&#160;</text><text
 class="breez [...]
 </text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="215.2" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-8)">│</text><text 
class="breeze-workflow-run-mirror-base-images-r1" x="658.8" y="215.2" 
textLength="146.4" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-8)">the&#160;mirror.&#160;</text><text
 class="breeze-workflow-run-mirror-base-images-r5" x="805.2" y="215.2" 
textLength="390.4" clip-path="url(#breeze-workflow-run-mirr [...]
-</text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="239.6" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-9)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-mirror-base-images-r1" x="1464" y="239.6" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-9)">
-</text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="264" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-10)">╭─</text><text 
class="breeze-workflow-run-mirror-base-images-r5" x="24.4" y="264" 
textLength="195.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-10)">&#160;Common&#160;options&#160;</text><text
 class="breeze-workflow-run-mirror-base-images-r5" x="219.6" y="264" 
textLength="1220" clip-path="url(#breeze-workflow-run- [...]
-</text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="288.4" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-11)">│</text><text 
class="breeze-workflow-run-mirror-base-images-r4" x="24.4" y="288.4" 
textLength="73.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-11)">--help</text><text
 class="breeze-workflow-run-mirror-base-images-r6" x="122" y="288.4" 
textLength="24.4" clip-path="url(#breeze-workflow-run-mirror-base-images-line 
[...]
-</text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="312.8" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-12)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-mirror-base-images-r1" x="1464" y="312.8" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-12)">
+</text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="239.6" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-9)">│</text><text 
class="breeze-workflow-run-mirror-base-images-r4" x="24.4" y="239.6" 
textLength="268.4" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-9)">--build-os-deps-images</text><text
 class="breeze-workflow-run-mirror-base-images-r1" x="292.8" y="239.6" 
textLength="12.2" clip-path="url(#breeze-workflow-run-mirror [...]
+</text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="264" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-10)">│</text><text 
class="breeze-workflow-run-mirror-base-images-r1" x="658.8" y="264" 
textLength="780.8" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-10)">images.&#160;Turn&#160;it&#160;off&#160;when&#160;the&#160;upgrade&#160;check&#160;is&#160;what&#160;runs&#160;the&#160;&#160;&#160;&#160;&#160;</text><text
 class="br [...]
+</text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="288.4" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-11)">│</text><text 
class="breeze-workflow-run-mirror-base-images-r1" x="658.8" y="288.4" 
textLength="658.8" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-11)">mirror:&#160;it&#160;pins&#160;the&#160;images&#160;the&#160;daily&#160;build&#160;published.&#160;</text><text
 class="breeze-workflow-run-mirror-base-images-r5"  [...]
+</text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="312.8" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-12)">│</text><text 
class="breeze-workflow-run-mirror-base-images-r5" x="658.8" y="312.8" 
textLength="256.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-12)">build-os-deps-images]</text><text
 class="breeze-workflow-run-mirror-base-images-r5" x="1451.8" y="312.8" 
textLength="12.2" clip-path="url(#breeze-workflow-run-mir [...]
+</text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="337.2" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-13)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-mirror-base-images-r1" x="1464" y="337.2" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-13)">
+</text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="361.6" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-14)">╭─</text><text 
class="breeze-workflow-run-mirror-base-images-r5" x="24.4" y="361.6" 
textLength="195.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-14)">&#160;Common&#160;options&#160;</text><text
 class="breeze-workflow-run-mirror-base-images-r5" x="219.6" y="361.6" 
textLength="1220" clip-path="url(#breeze-workflo [...]
+</text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="386" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-15)">│</text><text 
class="breeze-workflow-run-mirror-base-images-r4" x="24.4" y="386" 
textLength="73.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-15)">--help</text><text
 class="breeze-workflow-run-mirror-base-images-r6" x="122" y="386" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-15)"> [...]
+</text><text class="breeze-workflow-run-mirror-base-images-r5" x="0" y="410.4" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-16)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-mirror-base-images-r1" x="1464" y="410.4" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-mirror-base-images-line-16)">
 </text>
     </g>
     </g>
diff --git a/dev/breeze/doc/images/output_workflow-run_mirror-base-images.txt 
b/dev/breeze/doc/images/output_workflow-run_mirror-base-images.txt
index 35b73c90d6e..3b8f096f4d9 100644
--- a/dev/breeze/doc/images/output_workflow-run_mirror-base-images.txt
+++ b/dev/breeze/doc/images/output_workflow-run_mirror-base-images.txt
@@ -1 +1 @@
-ff6777b06b1340e4e0d47c58e828aae6
+68b613117ca8dfe035627ccd1951e78f
diff --git a/dev/breeze/src/airflow_breeze/commands/ci_commands.py 
b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
index ce0d2e19a2d..2823b1473f5 100644
--- a/dev/breeze/src/airflow_breeze/commands/ci_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
@@ -766,6 +766,7 @@ def mirror_base_images_before_upgrade(target_branch: str, 
github_token: str | No
         hint="Or skip it with --no-mirror-base-images.",
         # The mirror would otherwise start a second main upgrade next to this 
one.
         trigger_upgrade_check=target_branch != "main",
+        build_os_deps_images=False,
     )
 
 
diff --git 
a/dev/breeze/src/airflow_breeze/commands/release_management_commands.py 
b/dev/breeze/src/airflow_breeze/commands/release_management_commands.py
index cd2b9cf4f6d..595733d74b3 100644
--- a/dev/breeze/src/airflow_breeze/commands/release_management_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/release_management_commands.py
@@ -5441,10 +5441,11 @@ class OsDepsImageBuild(NamedTuple):
     image_type: OsDepsImageType
     python: str
     build_args: dict[str, str]
-    # The tag of the latest image built on this digest of the Python base 
image - the upgrade check
-    # pins the image it points at.
-    python_tag: str
-    # Every input of the image is in this tag, so an image is built only once.
+    # The tag of the latest published image of this Python patch release - the 
upgrade check pins the
+    # image it points at, whatever base image digest that was built on.
+    latest_tag: str
+    # Every input of the image is in this tag, so an image is built only once 
and the tag never moves -
+    # it is what keeps a pinned digest tagged after the latest tag has moved 
on.
     content_tag: str
 
 
@@ -5469,7 +5470,7 @@ def get_os_deps_image_builds(python: str | None, 
image_types: tuple[str, ...]) -
                     image_type=image_type,
                     python=python_version,
                     build_args=build_args,
-                    python_tag=os_deps_python_tag,
+                    latest_tag=get_os_deps_image_tag(image_type.name, 
python_version),
                     content_tag=f"{os_deps_python_tag}-{inputs_hash[:12]}",
                 )
             )
@@ -5583,14 +5584,13 @@ def publish_os_deps_images(python: str | None, 
image_types: tuple[str, ...]):
         if digest is None:
             failed.append(image)
             continue
-        python_tag_image = 
f"{HARDENED_OS_DEPS_IMAGE_REPOSITORY}:{build.python_tag}"
-        if get_image_digest(python_tag_image) == digest:
+        latest_image = 
f"{HARDENED_OS_DEPS_IMAGE_REPOSITORY}:{build.latest_tag}"
+        if get_image_digest(latest_image) == digest:
             continue
-        moving_tag_image = 
f"{HARDENED_OS_DEPS_IMAGE_REPOSITORY}:{get_os_deps_image_tag(build.image_type.name,
 build.python)}"
-        if copy_image(f"{image}@{digest}", [python_tag_image, 
moving_tag_image]):
-            moved.append(build.python_tag)
+        if copy_image(f"{image}@{digest}", [latest_image]):
+            moved.append(build.latest_tag)
         else:
-            failed.append(python_tag_image)
+            failed.append(latest_image)
     if moved:
         console_print(
             f"[warning]New OS-dependencies images for: {', '.join(moved)}. "
diff --git a/dev/breeze/src/airflow_breeze/commands/workflow_commands.py 
b/dev/breeze/src/airflow_breeze/commands/workflow_commands.py
index a1769f5a6cc..8c418a618bf 100644
--- a/dev/breeze/src/airflow_breeze/commands/workflow_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/workflow_commands.py
@@ -507,8 +507,19 @@ def workflow_run_release_constraints(version: str, ref: 
str, workflow_branch: st
     help="Let the mirror run the main upgrade check when a tag moves past its 
pin. Turn it off when the "
     "main upgrade check is what runs the mirror.",
 )
-def workflow_run_mirror_base_images(trigger_upgrade_check: bool):
-    mirror_hardened_base_images(APACHE_AIRFLOW_REPO, 
trigger_upgrade_check=trigger_upgrade_check)
[email protected](
+    "--build-os-deps-images/--no-build-os-deps-images",
+    default=True,
+    show_default=True,
+    help="Also build the OS-dependencies images on the mirrored base images. 
Turn it off when the "
+    "upgrade check is what runs the mirror: it pins the images the daily build 
published.",
+)
+def workflow_run_mirror_base_images(trigger_upgrade_check: bool, 
build_os_deps_images: bool):
+    mirror_hardened_base_images(
+        APACHE_AIRFLOW_REPO,
+        trigger_upgrade_check=trigger_upgrade_check,
+        build_os_deps_images=build_os_deps_images,
+    )
 
 
 @workflow_run_group.command(
diff --git a/dev/breeze/src/airflow_breeze/commands/workflow_commands_config.py 
b/dev/breeze/src/airflow_breeze/commands/workflow_commands_config.py
index 5b4eb705780..0dfe9fdaee0 100644
--- a/dev/breeze/src/airflow_breeze/commands/workflow_commands_config.py
+++ b/dev/breeze/src/airflow_breeze/commands/workflow_commands_config.py
@@ -93,6 +93,7 @@ WORKFLOW_RUN_PARAMETERS["breeze workflow-run 
mirror-base-images"] = [
         "name": "Mirror flags",
         "options": [
             "--trigger-upgrade-check",
+            "--build-os-deps-images",
         ],
     },
 ]
diff --git a/dev/breeze/src/airflow_breeze/global_constants.py 
b/dev/breeze/src/airflow_breeze/global_constants.py
index c3e33fa5682..95d9e722939 100644
--- a/dev/breeze/src/airflow_breeze/global_constants.py
+++ b/dev/breeze/src/airflow_breeze/global_constants.py
@@ -991,7 +991,7 @@ def get_hardened_python_image_retention_tag(tag: str, 
digest: str) -> str:
 # The OS dependencies of the hardened images come from the dhi.io apt 
repository, which is live: it
 # moves on - and drops the versions it replaced - under a base image pinned to 
a digest. The
 # "Mirror hardened base images" workflow therefore also builds images with the 
OS dependencies already
-# installed, one per installation type of install_os_dependencies.sh, and the 
CI and release builds
+# installed, daily, one per installation type of install_os_dependencies.sh. 
The CI and release builds
 # start from those, pinned to a digest the same way as the Python base images.
 HARDENED_OS_DEPS_IMAGE_REPOSITORY = "ghcr.io/apache/airflow/base/os-deps"
 
@@ -1033,12 +1033,12 @@ HARDENED_OS_DEPS_IMAGE_DIGESTS: dict[str, str] = 
json.loads(HARDENED_OS_DEPS_IMA
 
 
 def get_os_deps_image_tag(image_type: str, python: str) -> str:
-    """Return the moving tag of an OS-dependencies image - also the key of its 
pin."""
+    """Return the tag of the latest published OS-dependencies image - also the 
key of its pin."""
     return f"{image_type}-{get_hardened_python_image_tag(python, 
OS_DEPS_IMAGE_DEBIAN_VERSION)}"
 
 
 def get_os_deps_image_python_tag(image_type: str, python: str, python_digest: 
str) -> str:
-    """Return the tag of the latest OS-dependencies image built on one digest 
of the Python base image."""
+    """Return the prefix of the tags of the OS-dependencies images built on 
one Python base image digest."""
     return f"{get_os_deps_image_tag(image_type, 
python)}-{python_digest.removeprefix('sha256:')[:12]}"
 
 
diff --git a/dev/breeze/src/airflow_breeze/utils/gh_workflow_utils.py 
b/dev/breeze/src/airflow_breeze/utils/gh_workflow_utils.py
index 5362ed8a70e..47badcf49ef 100644
--- a/dev/breeze/src/airflow_breeze/utils/gh_workflow_utils.py
+++ b/dev/breeze/src/airflow_breeze/utils/gh_workflow_utils.py
@@ -315,12 +315,16 @@ def check_can_dispatch_workflows(repo: str, hint: str) -> 
None:
         sys.exit(1)
 
 
-def mirror_hardened_base_images(repo: str, hint: str = "", 
trigger_upgrade_check: bool = True) -> None:
+def mirror_hardened_base_images(
+    repo: str, hint: str = "", trigger_upgrade_check: bool = True, 
build_os_deps_images: bool = True
+) -> None:
     """
     Mirror the latest hardened base images by running the mirror workflow on 
main, and wait for it.
 
     :param trigger_upgrade_check: whether the mirror runs the main upgrade 
check when a tag moves past
         its pin - off when the caller is that upgrade, which would otherwise 
run twice.
+    :param build_os_deps_images: whether the run also builds the 
OS-dependencies images - off for the
+        upgrade check, which pins the ones the daily build published instead 
of waiting for new ones.
     """
     check_can_dispatch_workflows(repo, hint)
     console_print("[blue]Mirroring the latest hardened base images[/blue]")
@@ -330,5 +334,5 @@ def mirror_hardened_base_images(repo: str, hint: str = "", 
trigger_upgrade_check
         workflow_name=HARDENED_BASE_IMAGES_MIRROR_WORKFLOW,
         repo=repo,
         branch="main",
-        **{"trigger-upgrade-check": trigger_upgrade_check},
+        **{"trigger-upgrade-check": trigger_upgrade_check, 
"build-os-deps-images": build_os_deps_images},
     )
diff --git a/dev/breeze/tests/test_ci_upgrade.py 
b/dev/breeze/tests/test_ci_upgrade.py
index 022a407ae9a..08bbc3fcb07 100644
--- a/dev/breeze/tests/test_ci_upgrade.py
+++ b/dev/breeze/tests/test_ci_upgrade.py
@@ -161,6 +161,7 @@ def test_upgrade_mirrors_the_base_images_with_its_token(
         "apache/airflow",
         hint="Or skip it with --no-mirror-base-images.",
         trigger_upgrade_check=trigger_upgrade_check,
+        build_os_deps_images=False,
     )
 
 
diff --git a/dev/breeze/tests/test_gh_workflow_utils.py 
b/dev/breeze/tests/test_gh_workflow_utils.py
index b0dca2d3c7e..dde9348b962 100644
--- a/dev/breeze/tests/test_gh_workflow_utils.py
+++ b/dev/breeze/tests/test_gh_workflow_utils.py
@@ -206,7 +206,9 @@ def 
test_mirror_hardened_base_images_runs_the_mirror_on_main_after_the_check(moc
     calls.attach_mock(mock_check, "check")
     calls.attach_mock(mock_trigger, "trigger")
 
-    mirror_hardened_base_images("apache/airflow", hint="hint", 
trigger_upgrade_check=False)
+    mirror_hardened_base_images(
+        "apache/airflow", hint="hint", trigger_upgrade_check=False, 
build_os_deps_images=False
+    )
 
     assert calls.mock_calls == [
         mock.call.check("apache/airflow", "hint"),
@@ -214,6 +216,6 @@ def 
test_mirror_hardened_base_images_runs_the_mirror_on_main_after_the_check(moc
             workflow_name="mirror-hardened-base-images.yml",
             repo="apache/airflow",
             branch="main",
-            **{"trigger-upgrade-check": False},
+            **{"trigger-upgrade-check": False, "build-os-deps-images": False},
         ),
     ]
diff --git a/dev/breeze/tests/test_os_deps_images.py 
b/dev/breeze/tests/test_os_deps_images.py
index 7dd2b0ae1f2..49a6a9baaf4 100644
--- a/dev/breeze/tests/test_os_deps_images.py
+++ b/dev/breeze/tests/test_os_deps_images.py
@@ -240,9 +240,10 @@ def 
test_os_deps_image_builds_are_tagged_with_their_inputs(registry):
     builds = get_os_deps_image_builds("3.12", ("ci",))
 
     assert len(builds) == 1
-    assert builds[0].python_tag == f"ci-{PYTHON_TAG}-{'12' * 6}"
-    assert builds[0].content_tag.startswith(f"{builds[0].python_tag}-")
-    assert len(builds[0].content_tag) == len(builds[0].python_tag) + 13
+    assert builds[0].latest_tag == f"ci-{PYTHON_TAG}"
+    digest_prefix = f"ci-{PYTHON_TAG}-{'12' * 6}-"
+    assert builds[0].content_tag.startswith(digest_prefix)
+    assert len(builds[0].content_tag) == len(digest_prefix) + 12
 
 
 def test_os_deps_image_builds_fail_without_a_mirrored_base_image():
@@ -306,7 +307,7 @@ def 
test_publish_os_deps_images_merges_the_platforms_and_moves_the_tags(registry
         (platform_images, [f"{REPOSITORY}:{build.content_tag}"]),
         (
             [f"{REPOSITORY}:{build.content_tag}@{OS_DEPS_DIGEST}"],
-            [f"{REPOSITORY}:{build.python_tag}", 
f"{REPOSITORY}:ci-{PYTHON_TAG}"],
+            [f"{REPOSITORY}:ci-{PYTHON_TAG}"],
         ),
     ]
     assert github_output.read_text() == "moved-os-deps-images=true\n"
@@ -315,7 +316,7 @@ def 
test_publish_os_deps_images_merges_the_platforms_and_moves_the_tags(registry
 def test_publish_os_deps_images_does_not_move_tags_already_there(registry, 
tmp_path):
     (build,) = get_os_deps_image_builds("3.12", ("ci",))
     registry.digests[f"{REPOSITORY}:{build.content_tag}"] = OS_DEPS_DIGEST
-    registry.digests[f"{REPOSITORY}:{build.python_tag}"] = OS_DEPS_DIGEST
+    registry.digests[f"{REPOSITORY}:{build.latest_tag}"] = OS_DEPS_DIGEST
     github_output = tmp_path / "github_output"
 
     result = CliRunner().invoke(
diff --git a/dev/breeze/tests/test_workflow_commands.py 
b/dev/breeze/tests/test_workflow_commands.py
index 36c81673eb8..f97809810f8 100644
--- a/dev/breeze/tests/test_workflow_commands.py
+++ b/dev/breeze/tests/test_workflow_commands.py
@@ -395,18 +395,27 @@ class TestSyncStagingToMain:
 
 
 @pytest.mark.parametrize(
-    ("args", "trigger_upgrade_check"),
+    ("args", "trigger_upgrade_check", "build_os_deps_images"),
     [
-        pytest.param([], True, id="default"),
-        pytest.param(["--no-trigger-upgrade-check"], False, 
id="called-by-the-main-upgrade"),
+        pytest.param([], True, True, id="default"),
+        pytest.param(
+            ["--no-trigger-upgrade-check", "--no-build-os-deps-images"],
+            False,
+            False,
+            id="called-by-the-main-upgrade",
+        ),
     ],
 )
 
@patch("airflow_breeze.commands.workflow_commands.mirror_hardened_base_images", 
autospec=True)
-def test_mirror_base_images_runs_the_mirror(mock_mirror, args, 
trigger_upgrade_check):
+def test_mirror_base_images_runs_the_mirror(mock_mirror, args, 
trigger_upgrade_check, build_os_deps_images):
     result = CliRunner().invoke(workflow_run_mirror_base_images, args, 
catch_exceptions=False)
 
     assert result.exit_code == 0
-    mock_mirror.assert_called_once_with("apache/airflow", 
trigger_upgrade_check=trigger_upgrade_check)
+    mock_mirror.assert_called_once_with(
+        "apache/airflow",
+        trigger_upgrade_check=trigger_upgrade_check,
+        build_os_deps_images=build_os_deps_images,
+    )
 
 
 @patch("airflow_breeze.commands.workflow_commands.run_command", autospec=True)
diff --git a/docker-stack-docs/build.rst b/docker-stack-docs/build.rst
index 98738561563..7275c73b737 100644
--- a/docker-stack-docs/build.rst
+++ b/docker-stack-docs/build.rst
@@ -974,8 +974,8 @@ previous images that can matter when you extend or run the 
image are:
   repository: it moves on - and drops the package versions it replaced - 
whenever Docker rebuilds its
   images, so installing the OS dependencies from it can fail for a commit that 
built fine before. Airflow's
   CI and release builds therefore start their OS dependencies from images in 
which
-  ``install_os_dependencies.sh`` already ran, built on the mirrored base 
images and pinned to a digest by the
-  same upgrade, passed as ``DEV_OS_DEPS_IMAGE`` and ``RUNTIME_OS_DEPS_IMAGE``. 
The script records what it
+  ``install_os_dependencies.sh`` already ran, built daily on the mirrored base 
images and pinned to a digest
+  by the same upgrade, passed as ``DEV_OS_DEPS_IMAGE`` and 
``RUNTIME_OS_DEPS_IMAGE``. The script records what it
   installed with and does nothing when nothing differs, so a build with other 
apt dependencies still
   installs them. A plain ``docker build`` leaves both at ``BASE_IMAGE`` and 
installs everything as before.
 * **Packages come from Docker's apt repository first.** The image keeps stock 
Debian's apt sources beside
diff --git a/scripts/ci/prek/upgrade_important_versions.py 
b/scripts/ci/prek/upgrade_important_versions.py
index 7fbd739ed0b..00e84f787fd 100755
--- a/scripts/ci/prek/upgrade_important_versions.py
+++ b/scripts/ci/prek/upgrade_important_versions.py
@@ -526,33 +526,33 @@ def fetch_os_deps_image_digests(
     python_image_digests: dict[str, str], current_pins: dict[str, str]
 ) -> dict[str, str]:
     """
-    Fetch the digest to pin for every OS-dependencies image, given the base 
image digests being pinned.
+    Fetch the digest to pin for every OS-dependencies image of the Python 
patch releases being pinned.
 
-    The mirror workflow tags the latest OS-dependencies image built on each 
digest of a base image,
-    so the pins move together and the upgrade PR tests the images built on the 
base images it pins.
-    When there is none for a digest yet, the current pin of that image stays: 
it was built on an
-    older base image, but on one the full CI has passed with.
+    The mirror workflow builds the OS-dependencies images daily, while the 
base image pins move several
+    times a day, so the latest published image of each Python patch release is 
pinned - whatever base
+    image digest it was built on. It carries its own Python, so it need not 
share the base image pin's
+    digest. When none is published for a patch release yet - a new one, 
typically - the current pin
+    stays if it is for the same patch release, and the image is left unpinned 
otherwise.
     """
     pins: dict[str, str] = {}
-    for python_tag, python_digest in python_image_digests.items():
+    for python_tag in python_image_digests:
         if not 
python_tag.endswith(f"-debian{OS_DEPS_IMAGE_DEBIAN_RELEASE}-dev"):
             continue
         for image_type in OS_DEPS_IMAGE_TYPES:
             pin = f"{image_type}-{python_tag}"
-            tag = f"{pin}-{python_digest.removeprefix('sha256:')[:12]}"
-            digest = get_ghcr_image_digest(HARDENED_OS_DEPS_IMAGE_REPOSITORY, 
tag)
+            digest = get_ghcr_image_digest(HARDENED_OS_DEPS_IMAGE_REPOSITORY, 
pin)
             if digest:
                 pins[pin] = digest
             elif pin in current_pins:
                 console.print(
-                    
f"[yellow]ghcr.io/{HARDENED_OS_DEPS_IMAGE_REPOSITORY}:{tag} is not built yet - "
-                    f"keeping the current {pin} pin."
+                    
f"[yellow]ghcr.io/{HARDENED_OS_DEPS_IMAGE_REPOSITORY}:{pin} is not published - "
+                    "keeping the current pin."
                 )
                 pins[pin] = current_pins[pin]
             else:
                 console.print(
-                    
f"[yellow]ghcr.io/{HARDENED_OS_DEPS_IMAGE_REPOSITORY}:{tag} is not built yet - "
-                    f"{pin} stays unpinned and installs its OS dependencies 
from the apt repositories."
+                    
f"[yellow]ghcr.io/{HARDENED_OS_DEPS_IMAGE_REPOSITORY}:{pin} is not published 
yet - "
+                    "it stays unpinned and installs its OS dependencies from 
the apt repositories."
                 )
     return pins
 
diff --git a/scripts/tests/ci/prek/test_upgrade_important_versions.py 
b/scripts/tests/ci/prek/test_upgrade_important_versions.py
index 1214c4893b4..f41762feba4 100644
--- a/scripts/tests/ci/prek/test_upgrade_important_versions.py
+++ b/scripts/tests/ci/prek/test_upgrade_important_versions.py
@@ -519,11 +519,17 @@ CURRENT_OS_DEPS_DIGEST = "sha256:" + "cd" * 32
                 "ci-3.13.17-debian13-dev": OS_DEPS_DIGEST,
                 "dev-3.13.17-debian13-dev": CURRENT_OS_DEPS_DIGEST,
             },
-            id="not-built-keeps-the-current-pin-and-drops-old-patchlevels",
+            id="not-published-keeps-the-current-pin-and-drops-old-patchlevels",
+        ),
+        pytest.param(
+            set(),
+            {"ci-3.13.16-debian13-dev": OLD_DIGEST},
+            {},
+            id="new-patchlevel-not-published-stays-unpinned",
         ),
     ],
 )
-def test_os_deps_image_digests_follow_the_pinned_base_images(monkeypatch, 
built, current_pins, expected):
+def test_os_deps_image_digests_pin_the_latest_published_images(monkeypatch, 
built, current_pins, expected):
     from ci.prek import upgrade_important_versions as uiv
 
     looked_up = []
@@ -539,9 +545,8 @@ def 
test_os_deps_image_digests_follow_the_pinned_base_images(monkeypatch, built,
     )
 
     assert pins == expected
-    digest_tag = NEW_DIGEST.removeprefix("sha256:")[:12]
     assert looked_up == [
-        ("apache/airflow/base/os-deps", 
f"{image_type}-3.13.17-debian13-dev-{digest_tag}")
+        ("apache/airflow/base/os-deps", f"{image_type}-3.13.17-debian13-dev")
         for image_type in ("ci", "dev", "runtime")
     ]
 

Reply via email to