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 ca6861474f1 Run dependency explanations separately from main AMD 
canaries (#74311)
ca6861474f1 is described below

commit ca6861474f189f5ac26d5cecaad3300a12706375
Author: Yossi Eliaz <[email protected]>
AuthorDate: Sat Oct 31 22:50:42 2026 +0200

    Run dependency explanations separately from main AMD canaries (#74311)
    
    * Keep dependency upgrade explanations off the canary completion path
    
    Detailed upgrade investigations grow with the number of outdated packages 
and delay canary completion even when test preparation gets faster. Maintainers 
need prompt test results while retaining reproducible upgrade diagnostics.
    
    * Keep dependency reports from writing to the main branch cache
    
    Diagnostic reruns must retain their original inputs without gaining cache 
write access on the default branch. The workflow-run event already provides 
that boundary and the standard rerun action preserves the source canary.
    
    * Keep dependency reports aligned with supported Python versions
    
    * Keep dependency reports going when one input is gone
    
    - Skip entries whose saved constraints or CI image are missing or expired,
      with a warning and a summary line, instead of failing every report.
    - Name report inputs by canary attempt and explain each input once, so a
      re-run canary no longer queues a second report from the same inputs.
    - Post the same notice on every attempt when the canary saved no inputs.
    - Free up disk space and move Docker to /mnt before loading the image.
    - Save the checked constraints through breeze's --save-constraints-to,
      pinned to the resolved constraints commit; failing to save only warns,
      so the canary no longer depends on raw `gh api` calls.
    - Say that no cache step, not workflow_run, keeps the cache untouched, and
      skip the plan tests when node is unavailable.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * Give dependency explanations room for the slowest constraints mode
    
    Explaining constraints-source-providers took 105-120 minutes per job in
    the last four main canaries, past the report job's 90-minute timeout.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * Honor CONSTRAINTS_GITHUB_REPOSITORY when breeze downloads constraints
    
    CONSTRAINTS_GITHUB_REPOSITORY names the repository holding the
    constraints-* branches, apache/airflow by default, and the Dockerfiles
    read it. Breeze's download_constraints_file and get_branch_commit always
    used apache/airflow instead, so setting it changed nothing for
    constraints-version-check, the SBOM and provider dependency downloads,
    or the constraints commit saved for the dependency report.
    
    Both functions now read CONSTRAINTS_GITHUB_REPOSITORY and fall back to
    apache/airflow when it is unset or empty, so existing callers and CI on
    apache/airflow behave as before, and a saved commit always comes from
    the repository its constraints were downloaded from. Other files fetched
    through download_file_from_github still come from apache/airflow.
    
    * Explain dependencies from the image a RunsOn canary keeps in S3
    
    Since #74889 scheduled canaries run on RunsOn, where the CI image stash
    goes to RunsOn's S3 cache and only its digest is a GitHub artifact, so
    every report entry was skipped as "CI image missing". The plan now also
    accepts that digest artifact; the explain job then runs on a RunsOn
    runner, downloads exactly that tarball by its digest and checks it like
    stash-restore does. It prepares the disk with the workflow version's
    runner scripts, as the image build job now does.
    
    * Use the current RunsOn runner label for dependency reports from S3
    
    main changed breeze's RUNS_ON_AMD_RUNNER_TEMPLATE (instance families
    and spot strategy); the report's copy now matches it again, as the plan
    test that compares the two requires.
    
    * Describe re-used report inputs as saved and refuse parent segments in the 
S3 stash
    
    An earlier canary attempt only saved the inputs; its report may have 
failed, so the summary should not call them explained. The stash tarball is 
extracted with tar -P, so an entry with a .. segment could leave /mnt even 
under the /mnt prefix check.
    
    Generated-by: Claude Opus 5
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
    Co-authored-by: Shahar Epstein <[email protected]>
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 .github/workflows/dependency-upgrade-report.yml    | 254 +++++++++++++
 .github/workflows/finalize-tests.yml               |  29 +-
 dev/breeze/doc/ci/05_workflows.md                  |  45 +++
 ...elease-management_constraints-version-check.svg |  60 ++--
 ...elease-management_constraints-version-check.txt |   2 +-
 .../commands/release_management_commands.py        |  20 ++
 .../commands/release_management_commands_config.py |   2 +
 .../utils/constraints_version_check.py             |  67 +++-
 dev/breeze/src/airflow_breeze/utils/github.py      |  58 ++-
 dev/breeze/tests/test_constraints_version_check.py | 140 ++++++++
 dev/breeze/tests/test_dependency_upgrade_report.py | 396 +++++++++++++++++++++
 dev/breeze/tests/test_github_utils.py              |  81 +++++
 12 files changed, 1104 insertions(+), 50 deletions(-)

diff --git a/.github/workflows/dependency-upgrade-report.yml 
b/.github/workflows/dependency-upgrade-report.yml
new file mode 100644
index 00000000000..df9c7e33f85
--- /dev/null
+++ b/.github/workflows/dependency-upgrade-report.yml
@@ -0,0 +1,254 @@
+# 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: Dependency upgrade report
+# Both the job gate and the API validation reject PRs and foreign 
repositories. Only
+# completed main schedule/dispatch runs are executed, with a read-only token 
and no secrets.
+"on":  # zizmor: ignore[dangerous-triggers]
+  workflow_run:
+    workflows: ["Tests (AMD)"]
+    types: [completed]
+    branches: [main]
+permissions:
+  contents: read
+  actions: read
+# Every canary attempt fires this workflow and gets its own report of the 
inputs it saved.
+concurrency:
+  group: dependency-report-${{ github.event.workflow_run.id }}-${{ 
github.event.workflow_run.run_attempt }}
+  cancel-in-progress: false
+jobs:
+  plan:
+    runs-on: ubuntu-slim
+    timeout-minutes: 5
+    if: >-
+      github.event.workflow_run.head_repository.full_name == github.repository 
&&
+      (github.event.workflow_run.event == 'schedule' ||
+       github.event.workflow_run.event == 'workflow_dispatch')
+    outputs:
+      matrix: ${{ steps.plan.outputs.matrix }}
+      has-reports: ${{ steps.plan.outputs.has-reports }}
+      source-sha: ${{ steps.plan.outputs.source-sha }}
+      source-run-id: ${{ steps.plan.outputs.source-run-id }}
+    steps:
+      - name: "Select inputs from the original canary run"
+        id: plan
+        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3  
# v9.0.0
+        env:
+          SOURCE_RUN_ID: ${{ github.event.workflow_run.id }}
+          SOURCE_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
+        with:
+          script: |
+            const rawId = process.env.SOURCE_RUN_ID;
+            if (!/^[1-9][0-9]*$/.test(rawId)) throw new Error('Invalid source 
run ID');
+            const run_id = Number(rawId);
+            if (!Number.isSafeInteger(run_id)) throw new Error('Invalid source 
run ID');
+            const rawAttempt = process.env.SOURCE_RUN_ATTEMPT;
+            if (!/^[1-9][0-9]{0,3}$/.test(rawAttempt)) throw new 
Error('Invalid source run attempt');
+            const attempt_number = Number(rawAttempt);
+            const repo = context.repo;
+            const { data: run } = await 
github.rest.actions.getWorkflowRunAttempt(
+              { ...repo, run_id, attempt_number });
+            if (run.head_repository.full_name !== `${repo.owner}/${repo.repo}` 
||
+                run.head_branch !== 'main' || run.path !== 
'.github/workflows/ci-amd.yml' ||
+                !['schedule', 'workflow_dispatch'].includes(run.event) || 
run.status !== 'completed') {
+              throw new Error('Only completed main AMD canary runs are 
supported');
+            }
+            const artifacts = await 
github.paginate(github.rest.actions.listWorkflowRunArtifacts,
+              { ...repo, run_id, per_page: 100 });
+            const include = [];
+            const skipped = [];
+            const explained = [];
+            const inputName = new 
RegExp('^dependency-report-inputs-(3\\.[0-9]+)-' +
+              
'(constraints(?:-source-providers|-no-providers)?)-(true|false)-([1-9][0-9]*)$');
+            const saved = artifacts.map(a => [a, 
inputName.exec(a.name)]).filter(([, match]) => match);
+            // Only RunsOn runners can read RunsOn's S3 cache, where a canary 
on RunsOn keeps its CI image.
+            const runsOnRunner = 
`runs-on=${process.env.GITHUB_RUN_ID}/family=m8a+m8i+m7a+m7i+m6i` +
+              '/image=ubuntu22-full-x64/volume=150gb/spot=co/cpu=8/ram=32';
+            for (const [input, [, python, mode, useUv, attempt]] of saved) {
+              if (attempt !== rawAttempt) continue;
+              const entry = `${python}:${mode}`;
+              // A re-run canary saves its inputs again; they are explained 
only for the first attempt.
+              if (saved.some(([, m]) => m[1] === python && m[2] === mode && 
m[3] === useUv &&
+                  Number(m[4]) < attempt_number)) {
+                explained.push(entry);
+                continue;
+              }
+              // The stash action names the artifact after the stash key, with 
'/' as '_', and the branch.
+              // On RunsOn runners the image itself goes to RunsOn's S3 cache 
and the artifact is only
+              // its digest, stashed under the key plus '-s3' (see 
.github/actions/stash-save).
+              const imageName = `ci-image-save-v3-linux_amd64-${python}-main`;
+              const image = artifacts.find(a => a.name === imageName) ||
+                artifacts.find(a => a.name === 
`ci-image-save-v3-linux_amd64-${python}-s3-main`);
+              const missing = input.expired ? 'inputs expired' :
+                !image ? 'CI image missing' : image.expired ? 'CI image 
expired' : '';
+              if (missing) {
+                core.warning(`Skipping ${entry}: original inputs or CI image 
unavailable (${missing})`);
+                skipped.push(`${entry} (${missing})`);
+                continue;
+              }
+              const fromS3 = image.name !== imageName;
+              include.push({ python, mode, 'use-uv': useUv, 'inputs-id': 
input.id, 'image-id': image.id,
+                'image-source': fromS3 ? 's3' : 'artifact', runner: fromS3 ? 
runsOnRunner : 'ubuntu-22.04' });
+            }
+            core.setOutput('matrix', JSON.stringify({ include }));
+            core.setOutput('has-reports', include.length > 0 ? 'true' : 
'false');
+            core.setOutput('source-sha', run.head_sha);
+            core.setOutput('source-run-id', rawId);
+            if (!include.length && skipped.length && 
Number(process.env.GITHUB_RUN_ATTEMPT) > 1) {
+              throw new Error('Saved report inputs are missing or expired');
+            }
+            if (!include.length && !skipped.length) {
+              core.notice(explained.length ?
+                'The saved report inputs were already saved by an earlier 
canary attempt' :
+                'No saved report inputs: this run did not request independent 
diagnostics');
+            }
+            await core.summary.addHeading('Dependency upgrade diagnostics')
+              .addLink(`Source canary run (attempt ${rawAttempt})`, 
run.html_url)
+              .addRaw(`\n\nSource revision: ${run.head_sha}\n\nReports: 
${include.length}\n`)
+              .addRaw(skipped.length ? `\nSkipped: ${skipped.join(', ')}\n` : 
'')
+              .addRaw(explained.length ? `\nSaved by an earlier attempt: 
${explained.join(', ')}\n` : '')
+              .write();
+
+  explain:
+    needs: plan
+    if: needs.plan.outputs.has-reports == 'true'
+    name: "Explain ${{ matrix.python }}:${{ matrix.mode }}"
+    runs-on: ${{ matrix.runner }}
+    # Explaining constraints-source-providers took up to 115 minutes in the 
canary (Oct 2026).
+    timeout-minutes: 150
+    strategy:
+      fail-fast: false
+      max-parallel: 3
+      matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
+    env:
+      GITHUB_REPOSITORY: ${{ github.repository }}
+      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      GITHUB_USERNAME: ${{ github.actor }}
+      VERBOSE: "false"
+      USE_UV: ${{ matrix.use-uv }}
+    steps:
+      - name: "Checkout the tested revision"
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
+        with:
+          ref: ${{ needs.plan.outputs.source-sha }}
+          persist-credentials: false
+      # The disk is prepared as in the image build jobs: the loaded image and 
uv resolutions outgrow /,
+      # and the runner scripts come from the workflow's own version, which 
knows RunsOn's disks.
+      - name: "Checkout the workflow version for its actions and runner 
scripts"
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
+        with:
+          persist-credentials: false
+          path: workflow-version
+          sparse-checkout: |
+            .github/actions
+            scripts/ci
+      - name: "Free up disk space"
+        shell: bash
+        run: ./scripts/tools/free_up_disk_space.sh
+      - name: "Make /mnt writeable"
+        run: ./workflow-version/scripts/ci/make_mnt_writeable.sh
+      - name: "Move docker to /mnt"
+        run: ./workflow-version/scripts/ci/move_docker_to_mnt.sh
+      - name: "Install Breeze"
+        uses: ./.github/actions/breeze
+      - name: "Download the original CI image"
+        uses: 
actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333  # v8.0.2
+        with:
+          artifact-ids: ${{ matrix.image-id }}
+          run-id: ${{ needs.plan.outputs.source-run-id }}
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          merge-multiple: true
+          path: /mnt
+        if: matrix.image-source == 'artifact'
+      - name: "Download the digest of the original CI image"
+        uses: 
actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333  # v8.0.2
+        with:
+          artifact-ids: ${{ matrix.image-id }}
+          run-id: ${{ needs.plan.outputs.source-run-id }}
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          merge-multiple: true
+          path: ${{ runner.temp }}/ci-image-digest
+        if: matrix.image-source == 's3'
+      # The tarball is the one the canary stashed, addressed by its digest as 
in
+      # .github/actions/stash-restore, so a later build on `main` cannot 
replace it.
+      - name: "Restore the original CI image from the RunsOn cache"
+        shell: bash
+        run: |
+          set -euo pipefail
+          digest="$(cat "${RUNNER_TEMP}/ci-image-digest/sha256")"
+          if [[ ! "${digest}" =~ ^[0-9a-f]{64}$ ]]; then
+            echo "::error::Malformed digest of the original CI image"
+            exit 1
+          fi
+          export AWS_DEFAULT_REGION="${RUNS_ON_AWS_REGION:?not running on a 
RunsOn runner}"
+          bucket="${RUNS_ON_S3_BUCKET_CACHE:?not running on a RunsOn runner}"
+          object="s3://${bucket}/cache/airflow-stash/sha256/${digest}.tar"
+          payload="${RUNNER_TEMP}/ci-image-stash.tar"
+          aws s3 cp --only-show-errors "${object}" "${payload}"
+          echo "${digest}  ${payload}" | sha256sum --check --status
+          tar -tPf "${payload}" | while IFS= read -r entry; do
+            if [[ "${entry}" != /mnt/* || "${entry}" == */../* || "${entry}" 
== */.. ]]; then
+              echo "::error::The original CI image stash holds ${entry} 
outside /mnt"
+              exit 1
+            fi
+          done
+          tar -xPf "${payload}"
+          rm -f "${payload}"
+        if: matrix.image-source == 's3'
+      - name: "Load the original CI image"
+        env:
+          PYTHON_VERSION: ${{ matrix.python }}
+        run: breeze ci-image load --platform linux/amd64 --python 
"${PYTHON_VERSION}" --image-file-dir /mnt
+      - name: "Download the constraints used by the quick summary"
+        uses: 
actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333  # v8.0.2
+        with:
+          artifact-ids: ${{ matrix.inputs-id }}
+          run-id: ${{ needs.plan.outputs.source-run-id }}
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          merge-multiple: true
+          path: files/dependency-report
+      - name: "Explain outdated dependencies"
+        shell: bash
+        env:
+          PYTHON_VERSION: ${{ matrix.python }}
+          CONSTRAINTS_MODE: ${{ matrix.mode }}
+          SOURCE_RUN_ID: ${{ needs.plan.outputs.source-run-id }}
+          SOURCE_SHA: ${{ needs.plan.outputs.source-sha }}
+        run: |
+          {
+            echo "## Dependency upgrade explanations"
+            echo "Source canary run: ${SOURCE_RUN_ID}; source revision: 
${SOURCE_SHA}"
+            echo "Constraints commit: $(cat 
files/dependency-report/constraints-commit.txt)"
+            echo "Python: ${PYTHON_VERSION}; mode: ${CONSTRAINTS_MODE}"
+            echo "The constraints are the constraints branch the canary's 
summary checked, not ones built"
+            echo "from the source revision: dependency bounds changed between 
the two can show up below as"
+            echo "conflicts or as stale constraints."
+          } >> "${GITHUB_STEP_SUMMARY}"
+          breeze release-management constraints-version-check \
+            --python "${PYTHON_VERSION}" --airflow-constraints-mode 
"${CONSTRAINTS_MODE}" \
+            --constraints-file files/dependency-report/constraints.txt 
--explain-why \
+            2>&1 | tee files/dependency-report/explanations.txt
+      - name: "Save diagnostic output, including partial output on failure"
+        if: always()
+        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 
 # v7.0.2
+        with:
+          name: dependency-explanations-${{ matrix.python }}-${{ matrix.mode }}
+          path: files/dependency-report/
+          if-no-files-found: ignore
+          retention-days: 7
+          overwrite: true
diff --git a/.github/workflows/finalize-tests.yml 
b/.github/workflows/finalize-tests.yml
index d9d5c463b15..262cc2b9a66 100644
--- a/.github/workflows/finalize-tests.yml
+++ b/.github/workflows/finalize-tests.yml
@@ -153,16 +153,37 @@ jobs:
           python: ${{ matrix.python-version }}
           use-uv: ${{ inputs.use-uv }}
           make-mnt-writeable-and-cleanup: true
+      # Scheduled and dispatched canaries on main leave the explanations to 
dependency-upgrade-report.yml
+      # and save the constraints they checked for it. Saving them never fails 
this step.
       - name: "Deps: ${{ matrix.python-version }}:${{ matrix.constraints-mode 
}}"
         shell: bash
-        run: >
-          breeze release-management constraints-version-check
-          --python "${MATRIX_PYTHON_VERSION}"
-          --airflow-constraints-mode "${MATRIX_CONSTRAINTS_MODE}" --explain-why
+        run: |
+          REPORT_ARGS=(--explain-why)
+          if [[ "${INDEPENDENT_REPORT}" == "true" ]]; then
+            REPORT_ARGS=(--no-explain-why --save-constraints-to 
files/dependency-report)
+          fi
+          breeze release-management constraints-version-check \
+            --python "${MATRIX_PYTHON_VERSION}" \
+            --airflow-constraints-mode "${MATRIX_CONSTRAINTS_MODE}" 
"${REPORT_ARGS[@]}"
         env:
           MATRIX_PYTHON_VERSION: "${{ matrix.python-version }}"
           MATRIX_CONSTRAINTS_MODE: "${{ matrix.constraints-mode }}"
+          INDEPENDENT_REPORT: >-
+            ${{ github.ref == 'refs/heads/main' &&
+                (github.event_name == 'schedule' || github.event_name == 
'workflow_dispatch') }}
           VERBOSE: "false"
+      - name: "Upload dependency report inputs"
+        if: >-
+          !cancelled() && 
hashFiles('files/dependency-report/constraints-commit.txt') != ''
+        continue-on-error: true
+        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 
 # v7.0.2
+        with:
+          name: "dependency-report-inputs-${{ matrix.python-version }}-${{ 
matrix.constraints-mode }}\
+            -${{ inputs.use-uv }}-${{ github.run_attempt }}"
+          path: files/dependency-report/
+          if-no-files-found: error
+          retention-days: 2
+          overwrite: true
 
   push-buildx-cache-to-github-registry:
     name: Push Regular Image Cache ${{ inputs.platform }}
diff --git a/dev/breeze/doc/ci/05_workflows.md 
b/dev/breeze/doc/ci/05_workflows.md
index 0bbc0ffe5bb..a371d363004 100644
--- a/dev/breeze/doc/ci/05_workflows.md
+++ b/dev/breeze/doc/ci/05_workflows.md
@@ -563,6 +563,51 @@ unprivileged, which constrains what can go on it:
 When adding a job, reach for `ubuntu-slim` if it only shuffles metadata 
around, and
 `ubuntu-22.04` otherwise.
 
+### Dependency upgrade diagnostics
+
+Scheduled and manually dispatched AMD canaries on `main` keep the dependency 
freshness
+summary in `finalize-tests.yml`, but run it with `--no-explain-why`. Tests, 
constraint
+generation and validation, and image publication still run in the canary. 
Release-branch
+and other run types retain their existing explanation behavior. The summary 
saves the
+constraints it checked, and the constraints-branch commit they came from, with
+`--save-constraints-to`; a failure to save them only warns and never fails the 
canary.
+
+After the canary completes, `dependency-upgrade-report.yml` runs the detailed 
explanations
+independently, from the inputs the canary saved; a canary with failed jobs 
saves none. It checks out
+the original source SHA and downloads the original image and saved constraints 
by artifact
+ID from that run. It never substitutes the latest branch image or constraints: 
an entry whose
+saved constraints or image are missing or expired is skipped with a warning 
and listed in the
+summary, and the others are still explained. The image comes from the CI image 
stash, which
+keeps only the newest image per Python version and branch, so a later build on 
`main` can
+replace it within hours. A canary on RunsOn keeps the image in RunsOn's S3 
cache and stashes
+only its digest; the report job then runs on a RunsOn runner and downloads 
exactly that tarball
+by its digest. Re-running the canary triggers the workflow again; inputs that 
an earlier attempt
+already saved are not explained again.
+
+The saved constraints are the constraints branch as the summary saw it, not 
constraints
+generated from the source SHA, so dependency bounds that changed between the 
two can appear
+as conflicts or as stale constraints. The report summary shows both commits. 
PyPI release
+metadata is fetched when the report executes, so the available upgrade targets 
can change
+between the summary and the explanations.
+
+The workflow has read-only repository permissions. It runs on `workflow_run`, 
in the context
+of the default branch, so it could write that branch's GitHub Actions cache: 
what keeps the
+cache untouched is that no step saves one. Do not add caching (for example 
`actions/cache` or
+the cache option of a `setup-*` action) to this workflow. Reports use the 
saved image artifact.
+
+The report runs at most three jobs concurrently. Each job has a 150-minute 
limit and saves
+its output, including partial output on failure. A failed or timed-out report 
is incomplete;
+it does not invalidate the canary's test result. Find diagnostics in the 
**Dependency
+upgrade report** workflow, whose summary links to the source canary. To repeat 
diagnostics,
+use **Re-run jobs** on that report before its two-day input retention expires 
(or run
+`gh run rerun REPORT_RUN_ID`). Reruns keep the original source canary ID and 
attempt. A rerun
+fails when inputs were saved but none can be explained any more, rather than 
using a newer
+image or constraints; a report whose canary saved no inputs posts the same 
notice every time.
+
+`breeze release-management constraints-version-check --constraints-file PATH` 
can also
+analyze a saved constraints file locally. Detailed explanations reuse one 
baseline resolution
+and skip the pinned resolution when that baseline already selects the target 
version.
+
 ### Spot interruptions of self-hosted runners
 
 Runs of `apache/airflow` committers and the canary run on self-hosted
diff --git 
a/dev/breeze/doc/images/output_release-management_constraints-version-check.svg 
b/dev/breeze/doc/images/output_release-management_constraints-version-check.svg
index 1289420d214..2164202499c 100644
--- 
a/dev/breeze/doc/images/output_release-management_constraints-version-check.svg
+++ 
b/dev/breeze/doc/images/output_release-management_constraints-version-check.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 1482 782.0" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 1482 879.5999999999999" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -43,7 +43,7 @@
 
     <defs>
     <clipPath 
id="breeze-release-management-constraints-version-check-clip-terminal">
-      <rect x="0" y="0" width="1463.0" height="731.0" />
+      <rect x="0" y="0" width="1463.0" height="828.5999999999999" />
     </clipPath>
     <clipPath id="breeze-release-management-constraints-version-check-line-0">
     <rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -132,9 +132,21 @@
 <clipPath id="breeze-release-management-constraints-version-check-line-28">
     <rect x="0" y="684.7" width="1464" height="24.65"/>
             </clipPath>
+<clipPath id="breeze-release-management-constraints-version-check-line-29">
+    <rect x="0" y="709.1" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-release-management-constraints-version-check-line-30">
+    <rect x="0" y="733.5" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-release-management-constraints-version-check-line-31">
+    <rect x="0" y="757.9" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-release-management-constraints-version-check-line-32">
+    <rect x="0" y="782.3" 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="780" rx="8"/><text 
class="breeze-release-management-constraints-version-check-title" 
fill="#c5c8c6" text-anchor="middle" x="740" 
y="27">Command:&#160;release-management&#160;constraints-version-check</text>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="877.6" rx="8"/><text 
class="breeze-release-management-constraints-version-check-title" 
fill="#c5c8c6" text-anchor="middle" x="740" 
y="27">Command:&#160;release-management&#160;constraints-version-check</text>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
@@ -155,25 +167,29 @@
 </text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="215.2" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-8)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="215.2" textLength="317.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-8)">--airflow-constraints-mode</text><text
 class="breeze-release-management-constraints-version-check-r1" x="414.8" y 
[...]
 </text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="239.6" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-9)">│</text><text
 class="breeze-release-management-constraints-version-check-r5" x="414.8" 
y="239.6" textLength="353.8" 
clip-path="url(#breeze-release-management-constraints-version-check-line-9)">constraints-source-providers]</text><text
 class="breeze-release-management-constraints-version-check-r7" x="780. [...]
 </text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="264" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-10)">│</text><text
 class="breeze-release-management-constraints-version-check-r7" x="414.8" 
y="264" textLength="305" 
clip-path="url(#breeze-release-management-constraints-version-check-line-10)">constraints-no-providers)</text><text
 class="breeze-release-management-constraints-version-check-r5" x="1451.8" y="2 
[...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="288.4" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-11)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="288.4" textLength="317.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-11)">--github-repository&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-release-management-cons [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="312.8" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-12)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="312.8" textLength="317.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-12)">--github-token&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breez [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="337.2" textLength="1464" 
clip-path="url(#breeze-release-management-constraints-version-check-line-13)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-release-management-constraints-version-check-r1" x="1464" 
y="337.2" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-13)">
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="361.6" textLength="24.4" 
clip-path="url(#breeze-release-management-constraints-version-check-line-14)">╭─</text><text
 class="breeze-release-management-constraints-version-check-r5" x="24.4" 
y="361.6" textLength="219.6" 
clip-path="url(#breeze-release-management-constraints-version-check-line-14)">&#160;Comparison&#160;mode.&#160;</text><text
 class="breeze-release-management-constraints-version-check-r5" x [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="386" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-15)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="386" textLength="366" 
clip-path="url(#breeze-release-management-constraints-version-check-line-15)">--diff-mode&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#16
 [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="410.4" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-16)">│</text><text
 class="breeze-release-management-constraints-version-check-r7" x="414.8" 
y="410.4" textLength="390.4" 
clip-path="url(#breeze-release-management-constraints-version-check-line-16)">(full|diff-all|diff-constraints)</text><text
 class="breeze-release-management-constraints-version-check-r5" x= [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="434.8" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-17)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="434.8" textLength="366" 
clip-path="url(#breeze-release-management-constraints-version-check-line-17)">--package&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#
 [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="459.2" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-18)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="459.2" textLength="158.6" 
clip-path="url(#breeze-release-management-constraints-version-check-line-18)">--explain-why</text><text
 class="breeze-release-management-constraints-version-check-r1" x="183" 
y="459.2" text [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="483.6" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-19)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="483.6" textLength="366" 
clip-path="url(#breeze-release-management-constraints-version-check-line-19)">--cooldown-days&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><
 [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="508" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-20)">│</text><text
 class="breeze-release-management-constraints-version-check-r5" x="414.8" 
y="508" textLength="24.4" 
clip-path="url(#breeze-release-management-constraints-version-check-line-20)">4]</text><text
 class="breeze-release-management-constraints-version-check-r7" x="451.4" 
y="508" textLength="109.8"  [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="532.4" textLength="1464" 
clip-path="url(#breeze-release-management-constraints-version-check-line-21)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-release-management-constraints-version-check-r1" x="1464" 
y="532.4" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-21)">
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="556.8" textLength="24.4" 
clip-path="url(#breeze-release-management-constraints-version-check-line-22)">╭─</text><text
 class="breeze-release-management-constraints-version-check-r5" x="24.4" 
y="556.8" textLength="195.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-22)">&#160;Build&#160;options.&#160;</text><text
 class="breeze-release-management-constraints-version-check-r5" x=" [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="581.2" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-23)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="581.2" textLength="109.8" 
clip-path="url(#breeze-release-management-constraints-version-check-line-23)">--builder</text><text
 class="breeze-release-management-constraints-version-check-r1" x="158.6" 
y="581.2" textLe [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="605.6" textLength="1464" 
clip-path="url(#breeze-release-management-constraints-version-check-line-24)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-release-management-constraints-version-check-r1" x="1464" 
y="605.6" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-24)">
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="630" textLength="24.4" 
clip-path="url(#breeze-release-management-constraints-version-check-line-25)">╭─</text><text
 class="breeze-release-management-constraints-version-check-r5" x="24.4" 
y="630" textLength="195.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-25)">&#160;Common&#160;options&#160;</text><text
 class="breeze-release-management-constraints-version-check-r5" x="219. [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="654.4" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-26)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="654.4" textLength="109.8" 
clip-path="url(#breeze-release-management-constraints-version-check-line-26)">--verbose</text><text
 class="breeze-release-management-constraints-version-check-r6" x="158.6" 
y="654.4" textLe [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="678.8" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-27)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="678.8" textLength="109.8" 
clip-path="url(#breeze-release-management-constraints-version-check-line-27)">--dry-run</text><text
 class="breeze-release-management-constraints-version-check-r6" x="158.6" 
y="678.8" textLe [...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="703.2" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-28)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="703.2" textLength="109.8" 
clip-path="url(#breeze-release-management-constraints-version-check-line-28)">--help&#160;&#160;&#160;</text><text
 class="breeze-release-management-constraints-version-check-r6" x="158.6" y 
[...]
-</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="727.6" textLength="1464" 
clip-path="url(#breeze-release-management-constraints-version-check-line-29)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-release-management-constraints-version-check-r1" x="1464" 
y="727.6" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-29)">
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="288.4" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-11)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="288.4" textLength="317.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-11)">--constraints-file&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-release-management [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="312.8" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-12)">│</text><text
 class="breeze-release-management-constraints-version-check-r1" x="414.8" 
y="312.8" textLength="97.6" 
clip-path="url(#breeze-release-management-constraints-version-check-line-12)">branch.&#160;</text><text
 class="breeze-release-management-constraints-version-check-r7" x="512.4" 
y="312.8" te [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="337.2" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-13)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="337.2" textLength="317.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-13)">--save-constraints-to&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-release-management-constraints-ve [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="361.6" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-14)">│</text><text
 class="breeze-release-management-constraints-version-check-r1" x="414.8" 
y="361.6" textLength="817.4" 
clip-path="url(#breeze-release-management-constraints-version-check-line-14)">to&#160;save&#160;them&#160;is&#160;reported&#160;as&#160;a&#160;warning&#160;and&#160;does&#160;not&#160;fail
 [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="386" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-15)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="386" textLength="317.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-15)">--github-repository&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-release-management-constrai [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="410.4" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-16)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="410.4" textLength="317.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-16)">--github-token&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breez [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="434.8" textLength="1464" 
clip-path="url(#breeze-release-management-constraints-version-check-line-17)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-release-management-constraints-version-check-r1" x="1464" 
y="434.8" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-17)">
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="459.2" textLength="24.4" 
clip-path="url(#breeze-release-management-constraints-version-check-line-18)">╭─</text><text
 class="breeze-release-management-constraints-version-check-r5" x="24.4" 
y="459.2" textLength="219.6" 
clip-path="url(#breeze-release-management-constraints-version-check-line-18)">&#160;Comparison&#160;mode.&#160;</text><text
 class="breeze-release-management-constraints-version-check-r5" x [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="483.6" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-19)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="483.6" textLength="366" 
clip-path="url(#breeze-release-management-constraints-version-check-line-19)">--diff-mode&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;
 [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="508" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-20)">│</text><text
 class="breeze-release-management-constraints-version-check-r7" x="414.8" 
y="508" textLength="390.4" 
clip-path="url(#breeze-release-management-constraints-version-check-line-20)">(full|diff-all|diff-constraints)</text><text
 class="breeze-release-management-constraints-version-check-r5" x="145 [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="532.4" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-21)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="532.4" textLength="366" 
clip-path="url(#breeze-release-management-constraints-version-check-line-21)">--package&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#
 [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="556.8" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-22)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="556.8" textLength="158.6" 
clip-path="url(#breeze-release-management-constraints-version-check-line-22)">--explain-why</text><text
 class="breeze-release-management-constraints-version-check-r1" x="183" 
y="556.8" text [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="581.2" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-23)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="581.2" textLength="366" 
clip-path="url(#breeze-release-management-constraints-version-check-line-23)">--cooldown-days&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><
 [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="605.6" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-24)">│</text><text
 class="breeze-release-management-constraints-version-check-r5" x="414.8" 
y="605.6" textLength="24.4" 
clip-path="url(#breeze-release-management-constraints-version-check-line-24)">4]</text><text
 class="breeze-release-management-constraints-version-check-r7" x="451.4" 
y="605.6" textLength="1 [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="630" textLength="1464" 
clip-path="url(#breeze-release-management-constraints-version-check-line-25)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-release-management-constraints-version-check-r1" x="1464" 
y="630" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-25)">
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="654.4" textLength="24.4" 
clip-path="url(#breeze-release-management-constraints-version-check-line-26)">╭─</text><text
 class="breeze-release-management-constraints-version-check-r5" x="24.4" 
y="654.4" textLength="195.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-26)">&#160;Build&#160;options.&#160;</text><text
 class="breeze-release-management-constraints-version-check-r5" x=" [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="678.8" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-27)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="678.8" textLength="109.8" 
clip-path="url(#breeze-release-management-constraints-version-check-line-27)">--builder</text><text
 class="breeze-release-management-constraints-version-check-r1" x="158.6" 
y="678.8" textLe [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="703.2" textLength="1464" 
clip-path="url(#breeze-release-management-constraints-version-check-line-28)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-release-management-constraints-version-check-r1" x="1464" 
y="703.2" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-28)">
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="727.6" textLength="24.4" 
clip-path="url(#breeze-release-management-constraints-version-check-line-29)">╭─</text><text
 class="breeze-release-management-constraints-version-check-r5" x="24.4" 
y="727.6" textLength="195.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-29)">&#160;Common&#160;options&#160;</text><text
 class="breeze-release-management-constraints-version-check-r5" x=" [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="752" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-30)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="752" textLength="109.8" 
clip-path="url(#breeze-release-management-constraints-version-check-line-30)">--verbose</text><text
 class="breeze-release-management-constraints-version-check-r6" x="158.6" 
y="752" textLength=" [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="776.4" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-31)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="776.4" textLength="109.8" 
clip-path="url(#breeze-release-management-constraints-version-check-line-31)">--dry-run</text><text
 class="breeze-release-management-constraints-version-check-r6" x="158.6" 
y="776.4" textLe [...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="800.8" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-32)">│</text><text
 class="breeze-release-management-constraints-version-check-r4" x="24.4" 
y="800.8" textLength="109.8" 
clip-path="url(#breeze-release-management-constraints-version-check-line-32)">--help&#160;&#160;&#160;</text><text
 class="breeze-release-management-constraints-version-check-r6" x="158.6" y 
[...]
+</text><text class="breeze-release-management-constraints-version-check-r5" 
x="0" y="825.2" textLength="1464" 
clip-path="url(#breeze-release-management-constraints-version-check-line-33)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-release-management-constraints-version-check-r1" x="1464" 
y="825.2" textLength="12.2" 
clip-path="url(#breeze-release-management-constraints-version-check-line-33)">
 </text>
     </g>
     </g>
diff --git 
a/dev/breeze/doc/images/output_release-management_constraints-version-check.txt 
b/dev/breeze/doc/images/output_release-management_constraints-version-check.txt
index 7ed9535bd4c..78700f82fb0 100644
--- 
a/dev/breeze/doc/images/output_release-management_constraints-version-check.txt
+++ 
b/dev/breeze/doc/images/output_release-management_constraints-version-check.txt
@@ -1 +1 @@
-2c5c9bdbba4cde03848bc0c603c37d71
+c8bd7647f6101c627669eb67b8278432
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 96a66a0fed0..50252d42c40 100644
--- a/dev/breeze/src/airflow_breeze/commands/release_management_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/release_management_commands.py
@@ -5020,6 +5020,17 @@ def publish_schemas_to_s3(
 @option_builder
 @option_python
 @option_airflow_constraints_mode_ci
[email protected](
+    "--constraints-file",
+    type=click.Path(exists=True, dir_okay=False, path_type=Path),
+    help="Check this saved constraints file instead of downloading the current 
constraints branch.",
+)
[email protected](
+    "--save-constraints-to",
+    type=click.Path(file_okay=False, path_type=Path),
+    help="Also save the downloaded constraints and their commit to this 
directory. "
+    "A failure to save them is reported as a warning and does not fail the 
check.",
+)
 @click.option(
     "--diff-mode",
     type=click.Choice(["full", "diff-all", "diff-constraints"], 
case_sensitive=False),
@@ -5051,6 +5062,8 @@ def publish_schemas_to_s3(
 def version_check(
     python: str,
     airflow_constraints_mode: str,
+    constraints_file: Path | None,
+    save_constraints_to: Path | None,
     diff_mode,
     package: tuple[str],
     explain_why: bool,
@@ -5059,6 +5072,11 @@ def version_check(
     github_repository: str,
     builder: str,
 ):
+    if constraints_file and save_constraints_to:
+        console_print(
+            "[error]--save-constraints-to saves downloaded constraints, not a 
--constraints-file.[/]"
+        )
+        sys.exit(1)
     perform_environment_checks()
     fix_ownership_using_docker()
     cleanup_python_generated_files()
@@ -5081,6 +5099,8 @@ def version_check(
         github_token=github_token,
         github_repository=github_repository,
         cooldown_days=cooldown_days,
+        constraints_file=constraints_file,
+        save_constraints_to=save_constraints_to,
     )
 
 
diff --git 
a/dev/breeze/src/airflow_breeze/commands/release_management_commands_config.py 
b/dev/breeze/src/airflow_breeze/commands/release_management_commands_config.py
index 94de79045ac..a8df438fd6f 100644
--- 
a/dev/breeze/src/airflow_breeze/commands/release_management_commands_config.py
+++ 
b/dev/breeze/src/airflow_breeze/commands/release_management_commands_config.py
@@ -609,6 +609,8 @@ RELEASE_MANAGEMENT_PARAMETERS: dict[str, list[dict[str, str 
| list[str]]]] = {
             "options": [
                 "--python",
                 "--airflow-constraints-mode",
+                "--constraints-file",
+                "--save-constraints-to",
                 "--github-repository",
                 "--github-token",
             ],
diff --git a/dev/breeze/src/airflow_breeze/utils/constraints_version_check.py 
b/dev/breeze/src/airflow_breeze/utils/constraints_version_check.py
index 10c485fdd61..245d575740e 100755
--- a/dev/breeze/src/airflow_breeze/utils/constraints_version_check.py
+++ b/dev/breeze/src/airflow_breeze/utils/constraints_version_check.py
@@ -37,7 +37,7 @@ from airflow_breeze.global_constants import MOUNT_SELECTED
 from airflow_breeze.params.shell_params import ShellParams
 from airflow_breeze.utils.console import Output, console_print
 from airflow_breeze.utils.docker_command_utils import execute_command_in_shell
-from airflow_breeze.utils.github import download_constraints_file
+from airflow_breeze.utils.github import download_constraints_file, 
get_branch_commit
 from airflow_breeze.utils.packages import load_pyproject_toml
 from airflow_breeze.utils.parallel import get_temp_file_name
 from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH
@@ -302,6 +302,20 @@ def get_first_newer_release_date_str(releases, 
current_version):
         return None
 
 
+def save_constraints(constraints: str, constraints_commit: str | None, 
directory: Path) -> None:
+    """Keep the checked constraints for a later report. Failing to keep them 
never fails the check."""
+    if not constraints_commit:
+        console_print("[warning]Constraints not saved: the constraints commit 
could not be read.[/]")
+        return
+    try:
+        directory.mkdir(parents=True, exist_ok=True)
+        (directory / "constraints.txt").write_text(constraints)
+        # Written last, so that its presence means the copy is complete.
+        (directory / 
"constraints-commit.txt").write_text(f"{constraints_commit}\n")
+    except OSError as e:
+        console_print(f"[warning]Constraints not saved to {directory}: {e}[/]")
+
+
 def constraints_version_check(
     python: str,
     airflow_constraints_mode: str,
@@ -311,6 +325,8 @@ def constraints_version_check(
     github_token: str | None = None,
     github_repository: str | None = None,
     cooldown_days: int = 4,
+    constraints_file: Path | None = None,
+    save_constraints_to: Path | None = None,
 ):
     console_print(f"[bold cyan]Python version:[/] [white]{python}[/]")
     console_print(f"[bold cyan]Constraints mode:[/] 
[white]{airflow_constraints_mode}[/]")
@@ -320,16 +336,28 @@ def constraints_version_check(
         f"[bold cyan]Cooldown period:[/] [white]{cooldown_days} days[/] "
         f"[white]({exempt_count} distributions exempt via 
exclude-newer-package)[/]\n"
     )
-    with tempfile.TemporaryDirectory() as temp_dir:
-        constraints_file = Path(temp_dir) / "constraints.txt"
-        download_constraints_file(
-            constraints_reference=DEFAULT_AIRFLOW_CONSTRAINTS_BRANCH,
-            python_version=python,
-            airflow_constraints_mode=airflow_constraints_mode,
-            github_token=github_token,
-            output_file=constraints_file,
-        )
+    if constraints_file is not None:
         lines = constraints_file.read_text().splitlines()
+    else:
+        # A saved copy records its commit, so the branch is resolved first and 
downloaded at that commit.
+        constraints_commit = (
+            get_branch_commit(DEFAULT_AIRFLOW_CONSTRAINTS_BRANCH, github_token)
+            if save_constraints_to
+            else None
+        )
+        with tempfile.TemporaryDirectory() as temp_dir:
+            downloaded_file = Path(temp_dir) / "constraints.txt"
+            download_constraints_file(
+                constraints_reference=constraints_commit or 
DEFAULT_AIRFLOW_CONSTRAINTS_BRANCH,
+                python_version=python,
+                airflow_constraints_mode=airflow_constraints_mode,
+                github_token=github_token,
+                output_file=downloaded_file,
+            )
+            constraints = downloaded_file.read_text()
+        if save_constraints_to:
+            save_constraints(constraints, constraints_commit, 
save_constraints_to)
+        lines = constraints.splitlines()
     constraints_date = parse_constraints_generation_date(lines)
     if constraints_date:
         console_print(
@@ -804,12 +832,18 @@ def explain_package_upgrade(
     explanation = (
         f"[bold blue]\n--- Explaining for {pkg} (current: {pinned_version}, 
latest: {latest_version}) ---[/]"
     )
-    with preserve_files(AIRFLOW_ROOT_PATH / "pyproject.toml", 
AIRFLOW_ROOT_PATH / "uv.lock"):
-        from packaging.utils import canonicalize_name
+    # Breeze limits startup imports; packaging is needed only when explaining 
a package.
+    from packaging.utils import canonicalize_name
 
+    canonical_pkg = str(canonicalize_name(pkg))
+    baseline_version = baseline_versions.get(canonical_pkg)
+    if baseline_version == latest_version:
+        return explanation + (
+            f"\n[bold green]Package {pkg} already resolves to {latest_version} 
under "
+            f"--resolution highest. The constraints file appears to be 
stale.[/]"
+        )
+    with preserve_files(AIRFLOW_ROOT_PATH / "pyproject.toml", 
AIRFLOW_ROOT_PATH / "uv.lock"):
         airflow_pyproject = AIRFLOW_ROOT_PATH / "pyproject.toml"
-        canonical_pkg = str(canonicalize_name(pkg))
-        baseline_version = baseline_versions.get(canonical_pkg)
 
         update_pyproject_dependency(airflow_pyproject, pkg, latest_version, 
python_version)
         if get_verbose():
@@ -851,11 +885,6 @@ def explain_package_upgrade(
                 f"be read (empty freeze output), so the upgrade of {pkg} to 
{latest_version} "
                 f"could not be classified.[/]"
             )
-        elif baseline_version == latest_version:
-            explanation += (
-                f"\n[bold green]Package {pkg} already resolves to 
{latest_version} under "
-                f"--resolution highest. The constraints file appears to be 
stale.[/]"
-            )
         elif resolved_version != latest_version:
             explanation += (
                 f"\n[bold yellow]uv sync succeeded but {pkg} still resolved to 
"
diff --git a/dev/breeze/src/airflow_breeze/utils/github.py 
b/dev/breeze/src/airflow_breeze/utils/github.py
index 57b675f50d3..f2e47125a5b 100644
--- a/dev/breeze/src/airflow_breeze/utils/github.py
+++ b/dev/breeze/src/airflow_breeze/utils/github.py
@@ -29,6 +29,7 @@ from typing import TYPE_CHECKING, Any, cast
 
 from rich.markup import escape
 
+from airflow_breeze.global_constants import APACHE_AIRFLOW_GITHUB_REPOSITORY
 from airflow_breeze.utils.confirm import Answer, user_confirm
 from airflow_breeze.utils.console import console_print
 from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH
@@ -180,10 +181,15 @@ def log_github_rate_limit_error(response: Response) -> 
None:
 
 
 def download_file_from_github(
-    reference: str, path: str, output_file: Path, github_token: str | None = 
None, timeout: int = 60
+    reference: str,
+    path: str,
+    output_file: Path,
+    github_token: str | None = None,
+    timeout: int = 60,
+    github_repository: str = APACHE_AIRFLOW_GITHUB_REPOSITORY,
 ) -> bool:
     """
-    Downloads a file from the GitHub repository of Apache Airflow using the 
GitHub API.
+    Downloads a file from a GitHub repository (Apache Airflow by default) 
using the GitHub API.
 
     In case of any error different from 404, it will exit the process with 
error code 1.
 
@@ -192,11 +198,12 @@ def download_file_from_github(
     :param output_file: Path where the file should be downloaded
     :param github_token: GitHub token to use for authentication
     :param timeout: timeout in seconds for the download request, default is 60 
seconds
+    :param github_repository: repository to download from, default is 
apache/airflow
     :return: whether the file was successfully downloaded (False if the file 
is missing)
     """
     import requests
 
-    url = 
f"https://api.github.com/repos/apache/airflow/contents/{path}?ref={reference}";
+    url = 
f"https://api.github.com/repos/{github_repository}/contents/{path}?ref={reference}";
     console_print(f"[info]Downloading {url} to {output_file}")
     if not get_dry_run():
         headers = {"Accept": "application/vnd.github.v3.raw"}
@@ -299,6 +306,16 @@ def get_active_airflow_versions(
     return airflow_versions, airflow_release_dates
 
 
+def get_constraints_github_repository() -> str:
+    """
+    Returns the repository keeping the constraints branches.
+
+    It is CONSTRAINTS_GITHUB_REPOSITORY when set, the variable the Dockerfiles 
read for the same purpose,
+    and apache/airflow otherwise.
+    """
+    return os.environ.get("CONSTRAINTS_GITHUB_REPOSITORY") or 
APACHE_AIRFLOW_GITHUB_REPOSITORY
+
+
 def download_constraints_file(
     constraints_reference: str,
     python_version: str,
@@ -307,7 +324,7 @@ def download_constraints_file(
     output_file: Path,
 ) -> bool:
     """
-    Downloads constraints file from GitHub repository of Apache Airflow
+    Downloads constraints file from the constraints repository (see 
get_constraints_github_repository)
 
     :param constraints_reference: airflow version
     :param python_version: python version
@@ -322,9 +339,42 @@ def download_constraints_file(
         path=constraints_file_path,
         github_token=github_token,
         output_file=output_file,
+        github_repository=get_constraints_github_repository(),
     )
 
 
+def get_branch_commit(branch: str, github_token: str | None = None, timeout: 
int = 60) -> str | None:
+    """
+    Returns the commit a branch of the constraints repository (see 
get_constraints_github_repository)
+    points to.
+
+    Unlike the downloads above, it never exits the process: any error is 
reported as a warning.
+
+    :param branch: branch to resolve
+    :param github_token: GitHub token to use for authentication
+    :param timeout: timeout in seconds for the request, default is 60 seconds
+    :return: the commit SHA, or None if it could not be read
+    """
+    import requests
+
+    url = 
f"https://api.github.com/repos/{get_constraints_github_repository()}/commits/{branch}"
+    headers = {"Accept": "application/vnd.github.sha"}
+    if github_token:
+        headers["Authorization"] = f"Bearer {github_token}"
+        headers["X-GitHub-Api-Version"] = "2022-11-28"
+    try:
+        response = requests.get(url, headers=headers, timeout=timeout)
+    except requests.RequestException as e:
+        console_print(f"[warning]Could not read the commit of {branch}: {e}")
+        return None
+    commit = response.text.strip()
+    if response.status_code != 200 or not re.fullmatch(r"[0-9a-f]{40}", 
commit):
+        log_github_rate_limit_error(response)
+        console_print(f"[warning]Could not read the commit of {branch}. Status 
code {response.status_code}")
+        return None
+    return commit
+
+
 def get_tag_date(tag: str) -> str | None:
     """
     Returns UTC timestamp of the tag in the repo in iso time format 8601
diff --git a/dev/breeze/tests/test_constraints_version_check.py 
b/dev/breeze/tests/test_constraints_version_check.py
index 85856eb01fa..909fddfa0e3 100644
--- a/dev/breeze/tests/test_constraints_version_check.py
+++ b/dev/breeze/tests/test_constraints_version_check.py
@@ -24,7 +24,9 @@ from unittest import mock
 
 import pytest
 
+from airflow_breeze.branch_defaults import DEFAULT_AIRFLOW_CONSTRAINTS_BRANCH
 from airflow_breeze.utils.constraints_version_check import (
+    constraints_version_check,
     explain_package_upgrade,
     get_release_cutoff,
     get_table_format,
@@ -39,6 +41,144 @@ OLD_UPLOAD_TIME = "2020-01-01T00:00:00.000000Z"
 FRESH_UPLOAD_TIME = "2099-01-01T00:00:00.000000Z"
 
 
[email protected](f"{MODULE}.download_constraints_file", autospec=True)
[email protected](f"{MODULE}.load_cooldown_overrides", autospec=True, 
return_value={})
+def test_saved_constraints_are_used_without_downloading_current_branch(
+    mock_overrides, mock_download, tmp_path, pypi
+):
+    constraints_file = tmp_path / "constraints.txt"
+    constraints_file.write_text("pkg-a==1.0.0\n")
+    constraints_version_check(
+        python="3.14",
+        airflow_constraints_mode="constraints-source-providers",
+        diff_mode="full",
+        constraints_file=constraints_file,
+    )
+    mock_download.assert_not_called()
+
+
[email protected](f"{MODULE}.download_constraints_file", autospec=True)
[email protected](f"{MODULE}.load_cooldown_overrides", autospec=True, 
return_value={})
+def test_missing_saved_constraints_do_not_fall_back_to_current_branch(
+    mock_overrides, mock_download, tmp_path
+):
+    with pytest.raises(FileNotFoundError):
+        constraints_version_check(
+            python="3.14",
+            airflow_constraints_mode="constraints",
+            diff_mode="full",
+            constraints_file=tmp_path / "missing.txt",
+        )
+    mock_download.assert_not_called()
+
+
+def _download(*, output_file, **_):
+    output_file.write_text("pkg-a==1.0.0\n")
+    return True
+
+
[email protected](f"{MODULE}.download_constraints_file", autospec=True, 
side_effect=_download)
[email protected](f"{MODULE}.get_branch_commit", autospec=True, return_value="c" * 
40)
[email protected](f"{MODULE}.load_cooldown_overrides", autospec=True, 
return_value={})
+def 
test_downloaded_constraints_are_saved_with_the_commit_they_were_downloaded_at(
+    mock_overrides, mock_commit, mock_download, tmp_path, pypi
+):
+    constraints_version_check(
+        python="3.14",
+        airflow_constraints_mode="constraints",
+        diff_mode="full",
+        save_constraints_to=tmp_path / "saved",
+    )
+    assert mock_download.call_args.kwargs["constraints_reference"] == "c" * 40
+    assert (tmp_path / "saved" / "constraints.txt").read_text() == 
"pkg-a==1.0.0\n"
+    assert (tmp_path / "saved" / "constraints-commit.txt").read_text() == "c" 
* 40 + "\n"
+
+
[email protected]("requests.get")
[email protected](f"{MODULE}.load_cooldown_overrides", autospec=True, 
return_value={})
+def 
test_saved_constraints_and_their_commit_come_from_constraints_github_repository(
+    mock_overrides, mock_get, monkeypatch, tmp_path, pypi
+):
+    monkeypatch.setenv("CONSTRAINTS_GITHUB_REPOSITORY", "my-org/airflow")
+    commit = "c" * 40
+    mock_get.side_effect = lambda url, **_: (
+        mock.Mock(status_code=200, text=commit)
+        if "/commits/" in url
+        else mock.Mock(status_code=200, content=b"pkg-a==1.0.0\n")
+    )
+    constraints_version_check(
+        python="3.14",
+        airflow_constraints_mode="constraints",
+        diff_mode="full",
+        save_constraints_to=tmp_path / "saved",
+    )
+    assert [call.args[0] for call in mock_get.call_args_list] == [
+        
f"https://api.github.com/repos/my-org/airflow/commits/{DEFAULT_AIRFLOW_CONSTRAINTS_BRANCH}";,
+        
f"https://api.github.com/repos/my-org/airflow/contents/constraints-3.14.txt?ref={commit}";,
+    ]
+    assert (tmp_path / "saved" / "constraints-commit.txt").read_text() == 
commit + "\n"
+
+
[email protected](f"{MODULE}.download_constraints_file", autospec=True, 
side_effect=_download)
[email protected](f"{MODULE}.get_branch_commit", autospec=True, return_value=None)
[email protected](f"{MODULE}.load_cooldown_overrides", autospec=True, 
return_value={})
+def test_constraints_are_still_checked_when_their_commit_cannot_be_read(
+    mock_overrides, mock_commit, mock_download, tmp_path, pypi
+):
+    constraints_version_check(
+        python="3.14",
+        airflow_constraints_mode="constraints",
+        diff_mode="full",
+        save_constraints_to=tmp_path / "saved",
+    )
+    assert mock_download.call_args.kwargs["constraints_reference"] == 
DEFAULT_AIRFLOW_CONSTRAINTS_BRANCH
+    assert not (tmp_path / "saved").exists()
+
+
[email protected](f"{MODULE}.download_constraints_file", autospec=True, 
side_effect=_download)
[email protected](f"{MODULE}.get_branch_commit", autospec=True, return_value="c" * 
40)
[email protected](f"{MODULE}.load_cooldown_overrides", autospec=True, 
return_value={})
+def test_constraints_are_still_checked_when_they_cannot_be_saved(
+    mock_overrides, mock_commit, mock_download, tmp_path, pypi
+):
+    (tmp_path / "file").write_text("")
+    constraints_version_check(
+        python="3.14",
+        airflow_constraints_mode="constraints",
+        diff_mode="full",
+        save_constraints_to=tmp_path / "file" / "saved",
+    )
+    assert mock_download.call_count == 1
+
+
[email protected](f"{MODULE}.download_constraints_file", autospec=True, 
side_effect=_download)
[email protected](f"{MODULE}.get_branch_commit", autospec=True)
[email protected](f"{MODULE}.load_cooldown_overrides", autospec=True, 
return_value={})
+def test_constraints_branch_is_not_resolved_unless_saving(mock_overrides, 
mock_commit, mock_download, pypi):
+    constraints_version_check(python="3.14", 
airflow_constraints_mode="constraints", diff_mode="full")
+    mock_commit.assert_not_called()
+    assert mock_download.call_args.kwargs["constraints_reference"] == 
DEFAULT_AIRFLOW_CONSTRAINTS_BRANCH
+
+
[email protected](f"{MODULE}.update_pyproject_dependency", autospec=True)
[email protected](f"{MODULE}.sync_and_freeze", autospec=True)
[email protected]("package", ["pkg-a", "Pkg_A", "pkg.a"])
+def test_latest_baseline_version_needs_no_pinned_resolution(mock_sync, 
mock_update, package):
+    explanation = explain_package_upgrade(
+        pkg=package,
+        pinned_version="1.0.0",
+        latest_version="2.0.0",
+        python_version="3.14",
+        airflow_constraints_mode="constraints",
+        github_repository="apache/airflow",
+        baseline_text="successful baseline",
+        baseline_versions={"pkg-a": "2.0.0"},
+    )
+    mock_sync.assert_not_called()
+    mock_update.assert_not_called()
+    assert "already resolves to 2.0.0" in explanation
+
+
 def _pypi_payload(latest: str, *versions: str, fresh: str | None = None) -> 
bytes:
     releases = {
         v: [{"upload_time_iso_8601": FRESH_UPLOAD_TIME if v == fresh else 
OLD_UPLOAD_TIME, "yanked": False}]
diff --git a/dev/breeze/tests/test_dependency_upgrade_report.py 
b/dev/breeze/tests/test_dependency_upgrade_report.py
new file mode 100644
index 00000000000..489d88d71c5
--- /dev/null
+++ b/dev/breeze/tests/test_dependency_upgrade_report.py
@@ -0,0 +1,396 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import json
+import os
+import re
+import shutil
+import subprocess
+
+import pytest
+import yaml
+
+from airflow_breeze.global_constants import RUNS_ON_AMD_RUNNER_TEMPLATE
+from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH
+
+NODE_HARNESS = """
+import { readFileSync } from 'node:fs';
+const input = JSON.parse(readFileSync(0, 'utf8'));
+process.env.SOURCE_RUN_ID = input.id;
+process.env.SOURCE_RUN_ATTEMPT = input.sourceAttempt;
+process.env.GITHUB_RUN_ATTEMPT = String(input.attempt);
+process.env.GITHUB_RUN_ID = '456';
+const result = { outputs: {}, requests: [], notices: [], warnings: [], 
summary: '' };
+const summary = {
+  addHeading() { return this; }, addLink() { return this; },
+  addRaw(text) { result.summary += text; return this; }, async write() {}
+};
+const github = {
+  rest: { actions: {
+    async getWorkflowRunAttempt(args) { result.requests.push(args); return { 
data: input.run }; },
+    listWorkflowRunArtifacts() {}
+  } },
+  async paginate(method, args) { result.requests.push(args); return 
input.artifacts; }
+};
+const core = {
+  setOutput(key, value) { result.outputs[key] = value; }, summary,
+  notice(message) { result.notices.push(message); },
+  warning(message) { result.warnings.push(message); }
+};
+const context = { repo: { owner: 'apache', repo: 'airflow' } };
+const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
+try {
+  await new AsyncFunction('github', 'context', 'core', input.script)(github, 
context, core);
+} catch (error) {
+  result.error = error.message;
+}
+console.log(JSON.stringify(result));
+"""
+
+NO_INPUTS_NOTICE = "No saved report inputs: this run did not request 
independent diagnostics"
+
+requires_node = pytest.mark.skipif(shutil.which("node") is None, reason="The 
plan script runs in node")
+
+
[email protected]
+def source_run():
+    return {
+        "head_repository": {"full_name": "apache/airflow"},
+        "head_branch": "main",
+        "path": ".github/workflows/ci-amd.yml",
+        "event": "schedule",
+        "status": "completed",
+        "head_sha": "a" * 40,
+        "html_url": "https://github.com/apache/airflow/actions/runs/123";,
+    }
+
+
[email protected]
+def source_artifacts():
+    return [
+        {
+            "id": 1,
+            "name": 
"dependency-report-inputs-3.14-constraints-source-providers-true-1",
+            "expired": False,
+        },
+        {"id": 2, "name": "ci-image-save-v3-linux_amd64-3.14-main", "expired": 
False},
+    ]
+
+
+def load_workflow(path):
+    return yaml.safe_load((AIRFLOW_ROOT_PATH / ".github" / path).read_text())
+
+
+def summary_steps():
+    steps = 
load_workflow("workflows/finalize-tests.yml")["jobs"]["dependency-upgrade-summary"]["steps"]
+    return {step["name"]: step for step in steps}
+
+
+def run_plan(source_run, source_artifacts, *, attempt=1, source_run_id="123", 
source_attempt="1"):
+    workflow = load_workflow("workflows/dependency-upgrade-report.yml")
+    script = workflow["jobs"]["plan"]["steps"][0]["with"]["script"]
+    result = subprocess.run(
+        ["node", "--input-type=module", "--eval", NODE_HARNESS],
+        input=json.dumps(
+            {
+                "script": script,
+                "id": source_run_id,
+                "sourceAttempt": source_attempt,
+                "attempt": attempt,
+                "run": source_run,
+                "artifacts": source_artifacts,
+            }
+        ),
+        capture_output=True,
+        text=True,
+        check=True,
+        timeout=20,
+    )
+    return json.loads(result.stdout)
+
+
+def included_inputs(result):
+    return [entry["inputs-id"] for entry in 
json.loads(result["outputs"]["matrix"])["include"]]
+
+
+@requires_node
[email protected]("attempt", [1, 2])
+def test_plan_keeps_original_inputs_when_the_report_is_rerun(source_run, 
source_artifacts, attempt):
+    result = run_plan(source_run, source_artifacts, attempt=attempt)
+    outputs = result["outputs"]
+    assert outputs["source-run-id"] == "123"
+    assert outputs["source-sha"] == source_run["head_sha"]
+    assert outputs["has-reports"] == "true"
+    assert json.loads(outputs["matrix"])["include"] == [
+        {
+            "python": "3.14",
+            "mode": "constraints-source-providers",
+            "use-uv": "true",
+            "inputs-id": 1,
+            "image-id": 2,
+            "image-source": "artifact",
+            "runner": "ubuntu-22.04",
+        }
+    ]
+    assert all(request["run_id"] == 123 for request in result["requests"])
+    assert result["requests"][0]["attempt_number"] == 1
+
+
+@requires_node
[email protected]("attempt", [1, 2, 3])
+def 
test_plan_posts_the_same_notice_on_every_attempt_when_no_inputs_were_saved(source_run,
 attempt):
+    result = run_plan(source_run, [], attempt=attempt)
+    assert "error" not in result
+    assert result["outputs"]["has-reports"] == "false"
+    assert result["notices"] == [NO_INPUTS_NOTICE]
+
+
+@requires_node
[email protected]("attempt", [2, 3])
+def test_plan_fails_a_rerun_when_original_inputs_are_gone(source_run, 
source_artifacts, attempt):
+    result = run_plan(source_run, source_artifacts[:1], attempt=attempt)
+    assert result["error"] == "Saved report inputs are missing or expired"
+
+
[email protected](
+    ("field", "value"),
+    [
+        ("head_repository", {"full_name": "fork/airflow"}),
+        ("head_branch", "feature"),
+        ("event", "pull_request"),
+        ("path", ".github/workflows/other.yml"),
+        ("status", "in_progress"),
+    ],
+)
+@requires_node
+def test_plan_rejects_sources_outside_completed_main_canaries(source_run, 
source_artifacts, field, value):
+    source_run[field] = value
+    result = run_plan(source_run, source_artifacts)
+    assert result["error"] == "Only completed main AMD canary runs are 
supported"
+
+
+@requires_node
[email protected](
+    ("missing", "reason"),
+    [
+        ("image", "CI image missing"),
+        ("expired-image", "CI image expired"),
+        ("expired-input", "inputs expired"),
+    ],
+)
+def test_plan_does_not_substitute_newer_artifacts(source_run, 
source_artifacts, missing, reason):
+    if missing == "image":
+        source_artifacts.pop(1)
+    elif missing == "expired-image":
+        source_artifacts[1]["expired"] = True
+    else:
+        source_artifacts[0]["expired"] = True
+    source_artifacts += [
+        {"id": 3, "name": "dependency-report-inputs-3.13-constraints-true-1", 
"expired": False},
+        {"id": 4, "name": "ci-image-save-v3-linux_amd64-3.13-main", "expired": 
False},
+    ]
+    result = run_plan(source_run, source_artifacts)
+    skipped = f"3.14:constraints-source-providers ({reason})"
+    assert result["warnings"] == [
+        f"Skipping 3.14:constraints-source-providers: original inputs or CI 
image unavailable ({reason})"
+    ]
+    assert f"Skipped: {skipped}" in result["summary"]
+    assert included_inputs(result) == [3]
+
+
+@requires_node
[email protected](("source_attempt", "inputs_ids"), [("1", [1]), ("2", 
[3])])
+def 
test_plan_explains_inputs_only_for_the_first_canary_attempt_that_saved_them(
+    source_run, source_artifacts, source_attempt, inputs_ids
+):
+    source_artifacts += [
+        {
+            "id": 5,
+            "name": 
"dependency-report-inputs-3.14-constraints-source-providers-true-2",
+            "expired": False,
+        },
+        {"id": 3, "name": "dependency-report-inputs-3.13-constraints-true-2", 
"expired": False},
+        {"id": 4, "name": "ci-image-save-v3-linux_amd64-3.13-main", "expired": 
False},
+    ]
+    result = run_plan(source_run, source_artifacts, 
source_attempt=source_attempt)
+    assert result["requests"][0]["attempt_number"] == int(source_attempt)
+    assert included_inputs(result) == inputs_ids
+    if source_attempt == "2":
+        assert "Saved by an earlier attempt: 
3.14:constraints-source-providers" in result["summary"]
+
+
+@requires_node
+def 
test_plan_does_not_queue_a_second_report_from_inputs_already_explained(source_run,
 source_artifacts):
+    source_artifacts.append(
+        {
+            "id": 5,
+            "name": 
"dependency-report-inputs-3.14-constraints-source-providers-true-2",
+            "expired": False,
+        }
+    )
+    result = run_plan(source_run, source_artifacts, source_attempt="2")
+    assert result["outputs"]["has-reports"] == "false"
+    assert result["notices"] == ["The saved report inputs were already saved 
by an earlier canary attempt"]
+
+
+def stash_artifact_name(key, ref):
+    """The artifact name apache/infrastructure-actions/stash/save gives a 
stash (its shared/mung.py)."""
+    return "-".join(re.sub(r"[^_\-.\w]", "_", part) for part in (key, ref))
+
+
+def image_stash_key():
+    steps = 
load_workflow("workflows/ci-image-build.yml")["jobs"]["build-ci-images"]["steps"]
+    keys = [
+        step["with"]["key"]
+        for step in steps
+        if step.get("uses", "").endswith("/stash-save")
+        and step["with"]["key"].startswith("ci-image-save-")
+        and "image-stash-ref" not in step["with"]["key"]
+    ]
+    assert keys == ["ci-image-save-v3-${{ inputs.platform }}-${{ 
env.PYTHON_MAJOR_MINOR_VERSION }}"]
+    key = keys[0].replace("${{ inputs.platform }}", "linux/amd64")
+    return key.replace("${{ env.PYTHON_MAJOR_MINOR_VERSION }}", "3.14")
+
+
+def runs_on_digest_suffix():
+    """The suffix .github/actions/stash-save adds to the key of the digest it 
stashes on RunsOn."""
+    steps = load_workflow("actions/stash-save/action.yml")["runs"]["steps"]
+    digest = next(step for step in steps if step["name"] == "Save stash digest 
to GitHub artifacts")
+    return digest["with"]["key"].removeprefix("${{ inputs.key }}")
+
+
+@requires_node
[email protected](
+    ("runs_on", "image_source", "runner"),
+    [
+        pytest.param(False, "artifact", "ubuntu-22.04", 
id="github-hosted-canary"),
+        pytest.param(
+            True, "s3", 
RUNS_ON_AMD_RUNNER_TEMPLATE.format(run_id="456").format(8, 32), 
id="runs-on-canary"
+        ),
+    ],
+)
+def test_plan_finds_the_image_under_the_name_the_canary_stashes_it(
+    source_run, source_artifacts, runs_on, image_source, runner
+):
+    key = image_stash_key() + (runs_on_digest_suffix() if runs_on else "")
+    source_artifacts[1]["name"] = stash_artifact_name(key, "main")
+    [entry] = json.loads(run_plan(source_run, 
source_artifacts)["outputs"]["matrix"])["include"]
+    assert entry["inputs-id"] == 1
+    assert entry["image-id"] == 2
+    assert entry["image-source"] == image_source
+    assert entry["runner"] == runner
+
+
+def test_explain_restores_a_runs_on_image_where_stash_save_put_it():
+    stash_save = (AIRFLOW_ROOT_PATH / ".github" / "actions" / "stash-save" / 
"action.yml").read_text()
+    assert 'prefix="s3://${bucket}/cache/airflow-stash"' in stash_save
+    assert 'object="${prefix}/sha256/${digest}.tar"' in stash_save
+    steps = 
load_workflow("workflows/dependency-upgrade-report.yml")["jobs"]["explain"]["steps"]
+    by_name = {step["name"]: step for step in steps}
+    restore = by_name["Restore the original CI image from the RunsOn cache"]
+    assert 'object="s3://${bucket}/cache/airflow-stash/sha256/${digest}.tar"' 
in restore["run"]
+    assert "sha256sum --check" in restore["run"]
+    assert restore["if"] == "matrix.image-source == 's3'"
+    assert by_name["Download the digest of the original CI image"]["if"] == 
"matrix.image-source == 's3'"
+    assert by_name["Download the original CI image"]["if"] == 
"matrix.image-source == 'artifact'"
+    assert 
load_workflow("workflows/dependency-upgrade-report.yml")["jobs"]["explain"]["runs-on"]
 == (
+        "${{ matrix.runner }}"
+    )
+
+
+def test_each_canary_attempt_triggers_its_own_report():
+    workflow = load_workflow("workflows/dependency-upgrade-report.yml")
+    assert workflow["concurrency"]["group"].endswith("-${{ 
github.event.workflow_run.run_attempt }}")
+    assert workflow["concurrency"]["cancel-in-progress"] is False
+    plan_env = workflow["jobs"]["plan"]["steps"][0]["env"]
+    assert plan_env["SOURCE_RUN_ATTEMPT"] == "${{ 
github.event.workflow_run.run_attempt }}"
+    upload = summary_steps()["Upload dependency report inputs"]
+    assert upload["with"]["name"].endswith("-${{ github.run_attempt }}")
+
+
+def test_explain_prepares_the_disk_like_the_image_build_jobs():
+    build_steps = 
load_workflow("workflows/ci-image-build.yml")["jobs"]["build-ci-images"]["steps"]
+    steps = 
load_workflow("workflows/dependency-upgrade-report.yml")["jobs"]["explain"]["steps"]
+    names = [step["name"] for step in steps]
+    workflow_version = "Checkout the workflow version for its actions and 
runner scripts"
+    disk_steps = ["Free up disk space", "Make /mnt writeable", "Move docker to 
/mnt"]
+    assert names[1:6] == [workflow_version, *disk_steps, "Install Breeze"]
+    assert names.index("Install Breeze") < names.index("Download the original 
CI image")
+    build = {step["name"]: step for step in build_steps}
+    assert steps[names.index(workflow_version)]["with"] == 
build[workflow_version]["with"]
+    for name in disk_steps:
+        assert steps[names.index(name)]["run"] == build[name]["run"]
+
+
[email protected](
+    ("independent_report", "report_args"),
+    [
+        ("true", ["--no-explain-why", "--save-constraints-to", 
"files/dependency-report"]),
+        ("false", ["--explain-why"]),
+    ],
+)
+def test_canary_saves_report_inputs_through_breeze(tmp_path, 
independent_report, report_args):
+    steps = summary_steps()
+    assert not any("gh api" in step.get("run", "") for step in steps.values())
+    upload = steps["Upload dependency report inputs"]
+    assert "hashFiles('files/dependency-report/constraints-commit.txt')" in 
upload["if"]
+    assert upload["continue-on-error"] is True
+    deps = next(step for name, step in steps.items() if name.startswith("Deps: 
"))
+    (tmp_path / "breeze").write_text('#!/usr/bin/env bash\nprintf "%s\\n" 
"$@"\n')
+    (tmp_path / "breeze").chmod(0o755)
+    result = subprocess.run(
+        ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", 
deps["run"]],
+        env={
+            **os.environ,
+            "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}",
+            "INDEPENDENT_REPORT": independent_report,
+            "MATRIX_PYTHON_VERSION": "3.14",
+            "MATRIX_CONSTRAINTS_MODE": "constraints",
+        },
+        capture_output=True,
+        text=True,
+        check=True,
+        timeout=20,
+    )
+    assert result.stdout.splitlines() == [
+        "release-management",
+        "constraints-version-check",
+        "--python",
+        "3.14",
+        "--airflow-constraints-mode",
+        "constraints",
+        *report_args,
+    ]
+
+
+@requires_node
[email protected]("source_run_id", ["0", "abc", "123\ninjected", 
"9007199254740992"])
+def test_plan_rejects_invalid_source_ids_before_calling_github(source_run, 
source_artifacts, source_run_id):
+    result = run_plan(source_run, source_artifacts, 
source_run_id=source_run_id)
+    assert result["error"] == "Invalid source run ID"
+    assert result["requests"] == []
+
+
+@requires_node
[email protected]("source_attempt", ["0", "abc", "1\ninjected", 
"12345"])
+def test_plan_rejects_invalid_source_attempts_before_calling_github(
+    source_run, source_artifacts, source_attempt
+):
+    result = run_plan(source_run, source_artifacts, 
source_attempt=source_attempt)
+    assert result["error"] == "Invalid source run attempt"
+    assert result["requests"] == []
diff --git a/dev/breeze/tests/test_github_utils.py 
b/dev/breeze/tests/test_github_utils.py
index 3b2e2395f3e..c675d7a9f5a 100644
--- a/dev/breeze/tests/test_github_utils.py
+++ b/dev/breeze/tests/test_github_utils.py
@@ -22,8 +22,11 @@ from unittest import mock
 import pytest
 
 from airflow_breeze.utils.github import (
+    download_constraints_file,
+    download_file_from_github,
     env_without_github_tokens,
     format_github_token_scope_guidance,
+    get_branch_commit,
     retrieve_github_token,
     run_gh_command,
 )
@@ -182,3 +185,81 @@ def 
test_run_gh_command_skips_subprocess_in_dry_run(mock_run):
 
     assert result.returncode == 0
     mock_run.assert_not_called()
+
+
[email protected]("requests.get")
+def test_get_branch_commit_returns_the_commit_sha(mock_get, monkeypatch):
+    monkeypatch.delenv("CONSTRAINTS_GITHUB_REPOSITORY", raising=False)
+    mock_get.return_value = mock.Mock(status_code=200, text="c" * 40 + "\n")
+
+    assert get_branch_commit("constraints-main", github_token="token") == "c" 
* 40
+    assert (
+        mock_get.call_args.args[0] == 
"https://api.github.com/repos/apache/airflow/commits/constraints-main";
+    )
+    assert mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer 
token"
+
+
[email protected](
+    "response",
+    [
+        mock.Mock(status_code=403, text="", headers={}, 
**{"json.return_value": {"message": "rate limit"}}),
+        mock.Mock(status_code=200, text="<html>not a sha</html>", headers={}),
+    ],
+)
[email protected]("requests.get")
+def test_get_branch_commit_returns_none_instead_of_exiting(mock_get, response):
+    mock_get.return_value = response
+
+    assert get_branch_commit("constraints-main") is None
+
+
[email protected]("requests.get")
+def test_get_branch_commit_returns_none_when_github_is_unreachable(mock_get):
+    import requests
+
+    mock_get.side_effect = requests.ConnectionError("unreachable")
+
+    assert get_branch_commit("constraints-main") is None
+
+
[email protected](
+    ("constraints_github_repository", "expected_repository"),
+    [(None, "apache/airflow"), ("", "apache/airflow"), ("my-org/airflow", 
"my-org/airflow")],
+)
[email protected]("requests.get")
+def test_constraints_are_read_from_constraints_github_repository(
+    mock_get, constraints_github_repository, expected_repository, monkeypatch, 
tmp_path
+):
+    if constraints_github_repository is None:
+        monkeypatch.delenv("CONSTRAINTS_GITHUB_REPOSITORY", raising=False)
+    else:
+        monkeypatch.setenv("CONSTRAINTS_GITHUB_REPOSITORY", 
constraints_github_repository)
+    mock_get.return_value = mock.Mock(status_code=200, text="c" * 40, 
content=b"pkg-a==1.0.0\n")
+
+    assert get_branch_commit("constraints-main") == "c" * 40
+    assert download_constraints_file(
+        constraints_reference="c" * 40,
+        python_version="3.14",
+        github_token=None,
+        airflow_constraints_mode="constraints",
+        output_file=tmp_path / "constraints.txt",
+    )
+    assert [call.args[0] for call in mock_get.call_args_list] == [
+        
f"https://api.github.com/repos/{expected_repository}/commits/constraints-main";,
+        
f"https://api.github.com/repos/{expected_repository}/contents/constraints-3.14.txt?ref={'c'
 * 40}",
+    ]
+
+
[email protected]("requests.get")
+def test_other_files_are_still_downloaded_from_apache_airflow(mock_get, 
monkeypatch, tmp_path):
+    monkeypatch.setenv("CONSTRAINTS_GITHUB_REPOSITORY", "my-org/airflow")
+    mock_get.return_value = mock.Mock(status_code=200, content=b"{}")
+
+    assert download_file_from_github(
+        reference="3.1.0",
+        path="airflow-core/src/airflow/ui/package.json",
+        output_file=tmp_path / "package.json",
+    )
+    assert mock_get.call_args.args[0] == (
+        
"https://api.github.com/repos/apache/airflow/contents/airflow-core/src/airflow/ui/package.json?ref=3.1.0";
+    )

Reply via email to