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

shahar1 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new 923c798d1bf [v3-4-test] Let a final release override the 
pending-provider-candidate guard (#76592) (#76597)
923c798d1bf is described below

commit 923c798d1bfee0d7521f3aeecb68a4a244bb7a05
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Nov 6 15:50:58 2026 +0000

    [v3-4-test] Let a final release override the pending-provider-candidate 
guard (#76592) (#76597)
    
    * Let a final release override the pending-provider-candidate guard
    
    A final release-constraints run fails when a provider's newest PyPI version 
is a
    pending rc, so a release never ships constraints that silently drop its own
    provider wave (#75919). But an independent provider wave can overlap a core
    release - e.g. an accelerated release landing during a wave of breaking 
provider
    majors it was never tested against - and there the release should pin the
    currently-released providers and ship.
    
    Expose fail-on-pending-provider-candidates as an optional override on the
    release-constraints workflow. start-release asks before resolving, and the
    standalone 'breeze workflow-run release-constraints' takes the flag. Default
    behaviour is unchanged: a final still fails on a pending candidate unless 
the RM
    opts out, and the constraints never pin a release candidate either way.
    
    
    
    * Simplify the override prompt and fix its tests
    
    The default (no) keeps the guard; yes pins the currently-released 
providers. Mock
    the prompt in the regenerate_constraints tests and cover both answers.
    
    
    
    * Only prompt for the override when a provider candidate is actually pending
    
    Check PyPI for providers whose newest version is a pending rc, and offer the
    override only when the final constraints run would actually fail - no 
prompt on a
    normal release. Addresses review feedback. Detection reused from #76593.
    
    
    
    
    * handling review comments
    
    ---------
    (cherry picked from commit e0b7126d57325d35ec8acaacb1a5b22b1ac0b9c4)
    
    Co-authored-by: Rahul Vats <[email protected]>
    Co-authored-by: Claude <[email protected]>
    Co-authored-by: Amogh Desai <[email protected]>
    Co-authored-by: Amogh Desai <[email protected]>
---
 .github/workflows/release-constraints.yml          | 16 ++++-
 dev/README_RELEASE_AIRFLOW.md                      | 16 +++++
 .../output_workflow-run_release-constraints.svg    | 52 ++++++++++-----
 .../output_workflow-run_release-constraints.txt    |  2 +-
 .../src/airflow_breeze/commands/release_command.py | 37 +++++++++--
 .../airflow_breeze/commands/workflow_commands.py   | 14 +++-
 .../commands/workflow_commands_config.py           |  1 +
 .../airflow_breeze/utils/release_constraints.py    | 67 ++++++++++++++++++-
 dev/breeze/tests/test_release_command.py           | 77 +++++++++++++++++++++-
 dev/breeze/tests/test_release_constraints.py       | 65 ++++++++++++++++++
 dev/breeze/tests/test_workflow_commands.py         | 21 ++++++
 11 files changed, 342 insertions(+), 26 deletions(-)

diff --git a/.github/workflows/release-constraints.yml 
b/.github/workflows/release-constraints.yml
index cceb80cd5ee..78e71dc6dbf 100644
--- a/.github/workflows/release-constraints.yml
+++ b/.github/workflows/release-constraints.yml
@@ -43,6 +43,14 @@ on:  # yamllint disable-line rule:truthy
         description: "Ref the constraints are resolved from, e.g. v3-3-stable 
or the release tag"
         required: true
         type: string
+      fail-on-pending-provider-candidates:
+        description: >-
+          Override the final-release guard. Empty derives it from the version 
(a final fails when a
+          provider's newest PyPI version is a pending rc; a pre-release does 
not). Set to 'false' to
+          pin the currently-released providers and proceed through an 
overlapping provider wave.
+        required: false
+        default: ""
+        type: string
 permissions:
   contents: read
 concurrency:
@@ -187,9 +195,13 @@ jobs:
       # the constraints, which is exactly the version the release needed 
pinned.
       pypi-providers-only: "true"
       allow-pre-releases: ${{ needs.build-info.outputs.allow-pre-releases }}
-      # A final must not be cut while a provider candidate is still neither 
released nor yanked.
+      # A final must not be cut while a provider candidate is still neither 
released nor yanked -
+      # unless the release manager overrides it to pin the currently-released 
providers instead (for
+      # a final that deliberately ships past an overlapping, independent 
provider wave).
       fail-on-pending-provider-candidates: >-
-        ${{ needs.build-info.outputs.allow-pre-releases == 'true' && 'false' 
|| 'true' }}
+        ${{ inputs.fail-on-pending-provider-candidates != ''
+        && inputs.fail-on-pending-provider-candidates
+        || (needs.build-info.outputs.allow-pre-releases == 'true' && 'false' 
|| 'true') }}
       debug-resources: "false"
       checkout-ref: ${{ inputs.ref }}
       use-uv: "true"
diff --git a/dev/README_RELEASE_AIRFLOW.md b/dev/README_RELEASE_AIRFLOW.md
index 506dd7ee8d8..b8e8bc9b371 100644
--- a/dev/README_RELEASE_AIRFLOW.md
+++ b/dev/README_RELEASE_AIRFLOW.md
@@ -1523,6 +1523,22 @@ curl -s 
"https://raw.githubusercontent.com/apache/airflow/constraints-${VERSION_
 Agree with the providers' release manager on the decision early in the vote, 
so the final
 release does not wait on it.
 
+If the provider wave is independent of this release and you deliberately want 
to ship the final
+against the currently-released providers - for example an accelerated core 
release that overlaps a
+wave carrying breaking provider majors the release was not tested against - 
you can override the
+guard. ``start-release`` checks for pending provider candidates before 
anything is uploaded and
+asks whether to proceed; answer ``y`` to pin the currently-released providers, 
or ``n`` to stop the
+release until the candidates are released or yanked. The standalone workflow 
takes the same override:
+
+```shell script
+breeze workflow-run release-constraints --version ${VERSION} --ref v3-1-stable 
\
+  --fail-on-pending-provider-candidates false
+```
+
+The constraints still never pin a release candidate; the override only lets 
the run pin the newest
+*final* of each provider instead of stopping. The pending candidates are 
simply left out, exactly
+as they were before they were cut.
+
 ## Summarize the voting for the Apache Airflow release
 
 Once the vote has been passed, you will need to send a result vote to 
[email protected]:
diff --git a/dev/breeze/doc/images/output_workflow-run_release-constraints.svg 
b/dev/breeze/doc/images/output_workflow-run_release-constraints.svg
index e20e2d2696b..fbc946f168f 100644
--- a/dev/breeze/doc/images/output_workflow-run_release-constraints.svg
+++ b/dev/breeze/doc/images/output_workflow-run_release-constraints.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 1482 513.5999999999999" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 1482 635.5999999999999" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -45,7 +45,7 @@
 
     <defs>
     <clipPath id="breeze-workflow-run-release-constraints-clip-terminal">
-      <rect x="0" y="0" width="1463.0" height="462.59999999999997" />
+      <rect x="0" y="0" width="1463.0" height="584.5999999999999" />
     </clipPath>
     <clipPath id="breeze-workflow-run-release-constraints-line-0">
     <rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -101,9 +101,24 @@
 <clipPath id="breeze-workflow-run-release-constraints-line-17">
     <rect x="0" y="416.3" width="1464" height="24.65"/>
             </clipPath>
+<clipPath id="breeze-workflow-run-release-constraints-line-18">
+    <rect x="0" y="440.7" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-workflow-run-release-constraints-line-19">
+    <rect x="0" y="465.1" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-workflow-run-release-constraints-line-20">
+    <rect x="0" y="489.5" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-workflow-run-release-constraints-line-21">
+    <rect x="0" y="513.9" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-workflow-run-release-constraints-line-22">
+    <rect x="0" y="538.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="511.6" rx="8"/><text 
class="breeze-workflow-run-release-constraints-title" fill="#c5c8c6" 
text-anchor="middle" x="740" 
y="27">Command:&#160;workflow-run&#160;release-constraints</text>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="633.6" rx="8"/><text 
class="breeze-workflow-run-release-constraints-title" fill="#c5c8c6" 
text-anchor="middle" x="740" 
y="27">Command:&#160;workflow-run&#160;release-constraints</text>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
@@ -119,19 +134,24 @@
 </text><text class="breeze-workflow-run-release-constraints-r1" x="12.2" 
y="93.2" textLength="1037" 
clip-path="url(#breeze-workflow-run-release-constraints-line-3)">Trigger&#160;the&#160;workflow&#160;that&#160;resolves,&#160;publishes&#160;and&#160;tags&#160;the&#160;constraints&#160;for&#160;a&#160;release.</text><text
 class="breeze-workflow-run-release-constraints-r1" x="1464" y="93.2" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-3)">
 </text><text class="breeze-workflow-run-release-constraints-r1" x="1464" 
y="117.6" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-4)">
 </text><text class="breeze-workflow-run-release-constraints-r5" x="0" y="142" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-release-constraints-line-5)">╭─</text><text 
class="breeze-workflow-run-release-constraints-r5" x="24.4" y="142" 
textLength="292.8" 
clip-path="url(#breeze-workflow-run-release-constraints-line-5)">&#160;Constraints&#160;to&#160;resolve&#160;</text><text
 class="breeze-workflow-run-release-constraints-r5" x="317.2" y="142" 
textLength="1122.4" clip-path="url(#br [...]
-</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="166.4" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-6)">│</text><text 
class="breeze-workflow-run-release-constraints-r6" x="24.4" y="166.4" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-6)">*</text><text 
class="breeze-workflow-run-release-constraints-r4" x="61" y="166.4" 
textLength="207.4" 
clip-path="url(#breeze-workflow-run-release-constraints-line- [...]
-</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="190.8" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-7)">│</text><text 
class="breeze-workflow-run-release-constraints-r1" x="292.8" y="190.8" 
textLength="1146.8" 
clip-path="url(#breeze-workflow-run-release-constraints-line-7)">and&#160;lands&#160;on&#160;a&#160;branch&#160;of&#160;its&#160;own;&#160;a&#160;final&#160;(3.1.3)&#160;resolves&#160;without&#160;them&#160;and&#160;commits&
 [...]
-</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="215.2" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-8)">│</text><text 
class="breeze-workflow-run-release-constraints-r1" x="292.8" y="215.2" 
textLength="1146.8" 
clip-path="url(#breeze-workflow-run-release-constraints-line-8)">constraints-X-Y.&#160;The&#160;stage&#160;is&#160;derived&#160;from&#160;this,&#160;so&#160;it&#160;cannot&#160;be&#160;set&#160;inconsistently.&#160;&#160;&#1
 [...]
-</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="239.6" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-9)">│</text><text 
class="breeze-workflow-run-release-constraints-r7" x="292.8" y="239.6" 
textLength="122" 
clip-path="url(#breeze-workflow-run-release-constraints-line-9)">[required]</text><text
 class="breeze-workflow-run-release-constraints-r8" x="427" y="239.6" 
textLength="73.2" clip-path="url(#breeze-workflow-run-release-constrai [...]
-</text><text class="breeze-workflow-run-release-constraints-r5" x="0" y="264" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-10)">│</text><text 
class="breeze-workflow-run-release-constraints-r6" x="24.4" y="264" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-10)">*</text><text 
class="breeze-workflow-run-release-constraints-r4" x="61" y="264" 
textLength="207.4" 
clip-path="url(#breeze-workflow-run-release-constraints-line-10)" [...]
-</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="288.4" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-11)">│</text><text 
class="breeze-workflow-run-release-constraints-r8" x="292.8" y="288.4" 
textLength="73.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-11)">(TEXT)</text><text
 class="breeze-workflow-run-release-constraints-r5" x="1451.8" y="288.4" 
textLength="12.2" clip-path="url(#breeze-workflow-run-release-constr [...]
-</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="312.8" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-12)">│</text><text 
class="breeze-workflow-run-release-constraints-r4" x="61" y="312.8" 
textLength="207.4" 
clip-path="url(#breeze-workflow-run-release-constraints-line-12)">--workflow-branch</text><text
 class="breeze-workflow-run-release-constraints-r1" x="292.8" y="312.8" 
textLength="1146.8" clip-path="url(#breeze-workflow-run-rele [...]
-</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="337.2" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-13)">│</text><text 
class="breeze-workflow-run-release-constraints-r1" x="292.8" y="337.2" 
textLength="1146.8" 
clip-path="url(#breeze-workflow-run-release-constraints-line-13)">want:&#160;unlike&#160;the&#160;docs&#160;build,&#160;the&#160;constraints&#160;do&#160;not&#160;have&#160;to&#160;be&#160;produced&#160;by&#160;the&#160;wor
 [...]
-</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="361.6" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-14)">│</text><text 
class="breeze-workflow-run-release-constraints-r1" x="292.8" y="361.6" 
textLength="402.6" 
clip-path="url(#breeze-workflow-run-release-constraints-line-14)">stood&#160;at&#160;the&#160;ref&#160;being&#160;released.&#160;</text><text
 class="breeze-workflow-run-release-constraints-r5" x="695.4" y="361.6" 
textLength= [...]
-</text><text class="breeze-workflow-run-release-constraints-r5" x="0" y="386" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-release-constraints-line-15)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-release-constraints-r1" x="1464" y="386" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-15)">
-</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="410.4" textLength="24.4" 
clip-path="url(#breeze-workflow-run-release-constraints-line-16)">╭─</text><text
 class="breeze-workflow-run-release-constraints-r5" x="24.4" y="410.4" 
textLength="195.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-16)">&#160;Common&#160;options&#160;</text><text
 class="breeze-workflow-run-release-constraints-r5" x="219.6" y="410.4" 
textLength="1220" clip-path="url(#breeze-wo [...]
-</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="434.8" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-17)">│</text><text 
class="breeze-workflow-run-release-constraints-r4" x="24.4" y="434.8" 
textLength="73.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-17)">--help</text><text
 class="breeze-workflow-run-release-constraints-r9" x="122" y="434.8" 
textLength="24.4" clip-path="url(#breeze-workflow-run-release-constraint [...]
-</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="459.2" textLength="1464" 
clip-path="url(#breeze-workflow-run-release-constraints-line-18)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-release-constraints-r1" x="1464" y="459.2" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-18)">
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="166.4" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-6)">│</text><text 
class="breeze-workflow-run-release-constraints-r6" x="24.4" y="166.4" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-6)">*</text><text 
class="breeze-workflow-run-release-constraints-r4" x="61" y="166.4" 
textLength="451.4" 
clip-path="url(#breeze-workflow-run-release-constraints-line- [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="190.8" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-7)">│</text><text 
class="breeze-workflow-run-release-constraints-r1" x="536.8" y="190.8" 
textLength="902.8" 
clip-path="url(#breeze-workflow-run-release-constraints-line-7)">pre-releases&#160;allowed&#160;and&#160;lands&#160;on&#160;a&#160;branch&#160;of&#160;its&#160;own;&#160;a&#160;final&#160;(3.1.3)&#160;&#160;&#160;&#160;</text
 [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="215.2" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-8)">│</text><text 
class="breeze-workflow-run-release-constraints-r1" x="536.8" y="215.2" 
textLength="902.8" 
clip-path="url(#breeze-workflow-run-release-constraints-line-8)">resolves&#160;without&#160;them&#160;and&#160;commits&#160;onto&#160;constraints-X-Y.&#160;The&#160;stage&#160;is&#160;&#160;&#160;&#160;&#160;&#160;</text><tex
 [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="239.6" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-9)">│</text><text 
class="breeze-workflow-run-release-constraints-r1" x="536.8" y="239.6" 
textLength="671" 
clip-path="url(#breeze-workflow-run-release-constraints-line-9)">derived&#160;from&#160;this,&#160;so&#160;it&#160;cannot&#160;be&#160;set&#160;inconsistently.&#160;</text><text
 class="breeze-workflow-run-release-constraints-r7 [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" y="264" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-10)">│</text><text 
class="breeze-workflow-run-release-constraints-r6" x="24.4" y="264" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-10)">*</text><text 
class="breeze-workflow-run-release-constraints-r4" x="61" y="264" 
textLength="451.4" 
clip-path="url(#breeze-workflow-run-release-constraints-line-10)" [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="288.4" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-11)">│</text><text 
class="breeze-workflow-run-release-constraints-r1" x="536.8" y="288.4" 
textLength="158.6" 
clip-path="url(#breeze-workflow-run-release-constraints-line-11)">release&#160;tag.&#160;</text><text
 class="breeze-workflow-run-release-constraints-r7" x="695.4" y="288.4" 
textLength="122" clip-path="url(#breeze-workflow-ru [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="312.8" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-12)">│</text><text 
class="breeze-workflow-run-release-constraints-r4" x="61" y="312.8" 
textLength="451.4" 
clip-path="url(#breeze-workflow-run-release-constraints-line-12)">--workflow-branch&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="bree [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="337.2" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-13)">│</text><text 
class="breeze-workflow-run-release-constraints-r1" x="536.8" y="337.2" 
textLength="902.8" 
clip-path="url(#breeze-workflow-run-release-constraints-line-13)">normally&#160;what&#160;you&#160;want:&#160;unlike&#160;the&#160;docs&#160;build,&#160;the&#160;constraints&#160;do&#160;not&#160;have</text><text
 class="bree [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="361.6" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-14)">│</text><text 
class="breeze-workflow-run-release-constraints-r1" x="536.8" y="361.6" 
textLength="902.8" 
clip-path="url(#breeze-workflow-run-release-constraints-line-14)">to&#160;be&#160;produced&#160;by&#160;the&#160;workflow&#160;as&#160;it&#160;stood&#160;at&#160;the&#160;ref&#160;being&#160;released.&#160;&#160;&#160;&#160;
 [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" y="386" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-15)">│</text><text 
class="breeze-workflow-run-release-constraints-r5" x="536.8" y="386" 
textLength="183" 
clip-path="url(#breeze-workflow-run-release-constraints-line-15)">[default:&#160;main]</text><text
 class="breeze-workflow-run-release-constraints-r8" x="732" y="386" 
textLength="73.2" clip-path="url(#breeze-workflow-run-release-co [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="410.4" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-16)">│</text><text 
class="breeze-workflow-run-release-constraints-r4" x="61" y="410.4" 
textLength="451.4" 
clip-path="url(#breeze-workflow-run-release-constraints-line-16)">--fail-on-pending-provider-candidates</text><text
 class="breeze-workflow-run-release-constraints-r1" x="536.8" y="410.4" 
textLength="902.8" clip-path="url(#breez [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="434.8" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-17)">│</text><text 
class="breeze-workflow-run-release-constraints-r1" x="536.8" y="434.8" 
textLength="902.8" 
clip-path="url(#breeze-workflow-run-release-constraints-line-17)">final&#160;fails&#160;when&#160;a&#160;provider&#x27;s&#160;newest&#160;PyPI&#160;version&#160;is&#160;a&#160;pending&#160;rc).&#160;Set&#160;&#160;&#160;</te
 [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="459.2" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-18)">│</text><text 
class="breeze-workflow-run-release-constraints-r1" x="536.8" y="459.2" 
textLength="902.8" 
clip-path="url(#breeze-workflow-run-release-constraints-line-18)">&#x27;false&#x27;&#160;to&#160;pin&#160;the&#160;currently-released&#160;providers&#160;and&#160;proceed&#160;through&#160;an&#160;&#160;&#160;&#160;</text><t
 [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="483.6" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-19)">│</text><text 
class="breeze-workflow-run-release-constraints-r1" x="536.8" y="483.6" 
textLength="329.4" 
clip-path="url(#breeze-workflow-run-release-constraints-line-19)">overlapping&#160;provider&#160;wave.&#160;</text><text
 class="breeze-workflow-run-release-constraints-r8" x="866.2" y="483.6" 
textLength="146.4" clip-path="ur [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" y="508" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-release-constraints-line-20)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-release-constraints-r1" x="1464" y="508" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-20)">
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="532.4" textLength="24.4" 
clip-path="url(#breeze-workflow-run-release-constraints-line-21)">╭─</text><text
 class="breeze-workflow-run-release-constraints-r5" x="24.4" y="532.4" 
textLength="195.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-21)">&#160;Common&#160;options&#160;</text><text
 class="breeze-workflow-run-release-constraints-r5" x="219.6" y="532.4" 
textLength="1220" clip-path="url(#breeze-wo [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="556.8" textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-22)">│</text><text 
class="breeze-workflow-run-release-constraints-r4" x="24.4" y="556.8" 
textLength="73.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-22)">--help</text><text
 class="breeze-workflow-run-release-constraints-r9" x="122" y="556.8" 
textLength="24.4" clip-path="url(#breeze-workflow-run-release-constraint [...]
+</text><text class="breeze-workflow-run-release-constraints-r5" x="0" 
y="581.2" textLength="1464" 
clip-path="url(#breeze-workflow-run-release-constraints-line-23)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-release-constraints-r1" x="1464" y="581.2" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-release-constraints-line-23)">
 </text>
     </g>
     </g>
diff --git a/dev/breeze/doc/images/output_workflow-run_release-constraints.txt 
b/dev/breeze/doc/images/output_workflow-run_release-constraints.txt
index 871ae3667b4..a8be1202c38 100644
--- a/dev/breeze/doc/images/output_workflow-run_release-constraints.txt
+++ b/dev/breeze/doc/images/output_workflow-run_release-constraints.txt
@@ -1 +1 @@
-14273c8c9c667e0004c0a1379d53da58
+a692fbae91ea1080aa71e1e6936a6d94
diff --git a/dev/breeze/src/airflow_breeze/commands/release_command.py 
b/dev/breeze/src/airflow_breeze/commands/release_command.py
index ffa97a01f63..c6a9701ec08 100644
--- a/dev/breeze/src/airflow_breeze/commands/release_command.py
+++ b/dev/breeze/src/airflow_breeze/commands/release_command.py
@@ -29,7 +29,10 @@ from airflow_breeze.utils.confirm import confirm_action
 from airflow_breeze.utils.console import console_print
 from airflow_breeze.utils.environment_check import is_ci_environment
 from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH
-from airflow_breeze.utils.release_constraints import publish_constraints
+from airflow_breeze.utils.release_constraints import (
+    find_pending_provider_candidates,
+    publish_constraints,
+)
 from airflow_breeze.utils.run_utils import run_command
 
 # Pattern to match Airflow release versions (e.g., "3.0.5")
@@ -344,12 +347,36 @@ def upload_to_pypi(version, task_sdk_version=None):
         )
 
 
-def regenerate_constraints(version):
+def decide_pending_candidates_override() -> bool | None:
+    """Settle the pending-provider-candidate guard before anything 
irreversible happens.
+
+    The constraints are resolved after the PyPI upload, so a guard that trips 
there leaves the
+    release published without constraints. Returns ``False`` to pin the 
currently-released providers,
+    or ``None`` to let the workflow derive the guard when nothing is pending.
+    """
+    pending = find_pending_provider_candidates()
+    if not pending:
+        return None
+    console_print("[warning]These providers have a release candidate on PyPI 
newer than their latest final:")
+    for distribution, candidate in pending:
+        console_print(f"[warning]  * {distribution}=={candidate}")
+    if confirm_action(
+        "Pin the currently-released providers and proceed even though a 
provider wave is still in rc?"
+    ):
+        return False
+    exit("Release or yank the pending provider candidates, then start the 
release again.")
+
+
+def regenerate_constraints(version, fail_on_pending_provider_candidates: bool 
| None = None):
     # The candidate's constraints pinned pre-releases, so they cannot be 
promoted by retagging - a
     # released version must never point at rc pins. The workflow reads a 
version without "rcN" and
     # resolves again without them, against the providers now published as 
finals.
     major, minor = version.split(".")[:2]
-    publish_constraints(version=version, ref=f"v{major}-{minor}-stable")
+    publish_constraints(
+        version=version,
+        ref=f"v{major}-{minor}-stable",
+        
fail_on_pending_provider_candidates=fail_on_pending_provider_candidates,
+    )
 
 
 def tag_and_push_latest_constraint(version):
@@ -446,6 +473,8 @@ def airflow_release(version, task_sdk_version):
     # Final confirmation
     confirm_action("Pushes will be made to origin. Do you want to continue?", 
abort=True)
 
+    fail_on_pending_provider_candidates = decide_pending_candidates_override()
+
     # Clone the asf repo
     os.chdir("..")
     working_dir = os.getcwd()
@@ -536,7 +565,7 @@ def airflow_release(version, task_sdk_version):
     os.chdir(airflow_repo_root)
 
     # Regenerate, publish and tag the constraints for the final version
-    regenerate_constraints(version)
+    regenerate_constraints(version, fail_on_pending_provider_candidates)
     tag_and_push_latest_constraint(version)
 
     # Push tag for final version
diff --git a/dev/breeze/src/airflow_breeze/commands/workflow_commands.py 
b/dev/breeze/src/airflow_breeze/commands/workflow_commands.py
index a1769f5a6cc..dcbf5d3f640 100644
--- a/dev/breeze/src/airflow_breeze/commands/workflow_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/workflow_commands.py
@@ -481,18 +481,30 @@ def check_providers_are_in_site_index(doc_packages: 
tuple[str, ...], exclude_doc
     default="main",
     show_default=True,
 )
-def workflow_run_release_constraints(version: str, ref: str, workflow_branch: 
str):
[email protected](
+    "--fail-on-pending-provider-candidates",
+    type=click.Choice(["true", "false"]),
+    default=None,
+    help="Override the final-release guard. Unset derives it from the version 
(a final fails when a "
+    "provider's newest PyPI version is a pending rc). Set 'false' to pin the 
currently-released "
+    "providers and proceed through an overlapping provider wave.",
+)
+def workflow_run_release_constraints(
+    version: str, ref: str, workflow_branch: str, 
fail_on_pending_provider_candidates: str | None
+):
     if not RELEASE_VERSION_PATTERN.match(version):
         console_print(f"[red]Error: '{version}' is not a release version - 
expected X.Y.Z or X.Y.ZrcN.[/red]")
         sys.exit(1)
     stage = "candidate" if "rc" in version else "final"
     console_print(f"[blue]Triggering constraints generation for the {stage} 
{version} from {ref}[/blue]")
+    overrides = {"fail-on-pending-provider-candidates": 
fail_on_pending_provider_candidates or ""}
     trigger_workflow_and_monitor(
         workflow_name=WORKFLOW_NAME_MAPS["release-constraints"],
         repo=APACHE_AIRFLOW_REPO,
         branch=workflow_branch,
         version=version,
         ref=ref,
+        **overrides,
     )
 
 
diff --git a/dev/breeze/src/airflow_breeze/commands/workflow_commands_config.py 
b/dev/breeze/src/airflow_breeze/commands/workflow_commands_config.py
index 5b4eb705780..3b8e9047489 100644
--- a/dev/breeze/src/airflow_breeze/commands/workflow_commands_config.py
+++ b/dev/breeze/src/airflow_breeze/commands/workflow_commands_config.py
@@ -69,6 +69,7 @@ WORKFLOW_RUN_PARAMETERS["breeze workflow-run 
release-constraints"] = [
             "--version",
             "--ref",
             "--workflow-branch",
+            "--fail-on-pending-provider-candidates",
         ],
     },
 ]
diff --git a/dev/breeze/src/airflow_breeze/utils/release_constraints.py 
b/dev/breeze/src/airflow_breeze/utils/release_constraints.py
index 2565e430608..e4544ad8202 100644
--- a/dev/breeze/src/airflow_breeze/utils/release_constraints.py
+++ b/dev/breeze/src/airflow_breeze/utils/release_constraints.py
@@ -28,15 +28,67 @@ separate switch that could disagree with it.
 
 from __future__ import annotations
 
+from concurrent.futures import ThreadPoolExecutor
+
 from airflow_breeze.utils.confirm import confirm_action
 from airflow_breeze.utils.console import console_print
 from airflow_breeze.utils.gh_workflow_utils import trigger_workflow_and_monitor
 
 RELEASE_CONSTRAINTS_WORKFLOW = "release-constraints.yml"
 APACHE_AIRFLOW_REPO = "apache/airflow"
+PYPI_JSON_API_URL = "https://pypi.org/pypi/{distribution}/json";
+PYPI_LOOKUP_PARALLELISM = 16
+
+
+def _find_newest_published_version(distribution: str) -> str | None:
+    """Newest non-yanked version of ``distribution`` on PyPI, candidates 
included."""
+    import requests
+    from packaging.version import InvalidVersion, Version
+
+    response = 
requests.get(PYPI_JSON_API_URL.format(distribution=distribution), timeout=60)
+    if response.status_code == 404:
+        return None
+    response.raise_for_status()
+    newest: Version | None = None
+    for version, files in response.json().get("releases", {}).items():
+        try:
+            parsed = Version(version)
+        except InvalidVersion:
+            continue
+        if not any(not pypi_file.get("yanked") for pypi_file in files):
+            continue
+        if newest is None or parsed > newest:
+            newest = parsed
+    return str(newest) if newest else None
+
 
+def find_pending_provider_candidates() -> list[tuple[str, str]]:
+    """Providers whose newest PyPI version is a candidate - what the final 
constraints run refuses."""
+    from packaging.version import Version
+
+    from airflow_breeze.utils.provider_dependencies import 
get_provider_dependencies
+
+    distributions = [
+        f"apache-airflow-providers-{provider_id.replace('.', '-')}"
+        for provider_id, metadata in 
sorted(get_provider_dependencies().items())
+        if metadata["state"] == "ready"
+    ]
+    with ThreadPoolExecutor(max_workers=PYPI_LOOKUP_PARALLELISM) as executor:
+        newest_versions = list(executor.map(_find_newest_published_version, 
distributions))
+    return [
+        (distribution, version)
+        for distribution, version in zip(distributions, newest_versions)
+        if version is not None and Version(version).is_prerelease
+    ]
 
-def publish_constraints(*, version: str, ref: str, workflow_branch: str = 
"main") -> None:
+
+def publish_constraints(
+    *,
+    version: str,
+    ref: str,
+    workflow_branch: str = "main",
+    fail_on_pending_provider_candidates: bool | None = None,
+) -> None:
     """Resolve, publish and tag the constraints belonging to ``version``.
 
     ``version`` alone decides what happens: a candidate (``3.3.1rc1``) pins 
the providers at the
@@ -45,6 +97,11 @@ def publish_constraints(*, version: str, ref: str, 
workflow_branch: str = "main"
     the same candidate replaces that branch and its tag, so a wave can be 
re-cut. A final
     (``3.3.1``) resolves against the published releases and commits onto 
``constraints-X-Y``, which
     is what makes the released constraints the baseline everything downstream 
reads.
+
+    ``fail_on_pending_provider_candidates`` overrides the workflow's own 
derivation. Left ``None``
+    the workflow derives it from the version (a final fails when a provider's 
newest PyPI version is
+    a pending rc); ``False`` pins the currently-released providers and 
proceeds, for a final that
+    deliberately ships past an overlapping, independent provider wave.
     """
     from packaging.version import Version
 
@@ -60,11 +117,19 @@ def publish_constraints(*, version: str, ref: str, 
workflow_branch: str = "main"
     ):
         return
     console_print(f"[info]Resolving constraints for {version} from {ref} - 
this runs on CI runners.")
+    # The workflow input name is hyphenated, so it cannot be a keyword 
argument; "" lets the
+    # workflow derive the guard from the version, which is the default when no 
override is given.
+    overrides = {
+        "fail-on-pending-provider-candidates": ""
+        if fail_on_pending_provider_candidates is None
+        else str(fail_on_pending_provider_candidates).lower()
+    }
     trigger_workflow_and_monitor(
         workflow_name=RELEASE_CONSTRAINTS_WORKFLOW,
         repo=APACHE_AIRFLOW_REPO,
         branch=workflow_branch,
         version=version,
         ref=ref,
+        **overrides,
     )
     console_print(f"[success]Constraints for {version} published and tagged 
'constraints-{version}'")
diff --git a/dev/breeze/tests/test_release_command.py 
b/dev/breeze/tests/test_release_command.py
index c1d89193e4f..5becca58768 100644
--- a/dev/breeze/tests/test_release_command.py
+++ b/dev/breeze/tests/test_release_command.py
@@ -547,7 +547,11 @@ def 
test_regenerate_constraints_resolves_from_the_stable_branch(monkeypatch, rel
 
     release_cmd.regenerate_constraints("3.3.0")
 
-    assert calls == {"version": "3.3.0", "ref": "v3-3-stable"}
+    assert calls == {
+        "version": "3.3.0",
+        "ref": "v3-3-stable",
+        "fail_on_pending_provider_candidates": None,
+    }
 
 
 def test_regenerate_constraints_passes_a_final_version(monkeypatch, 
release_cmd):
@@ -559,3 +563,74 @@ def 
test_regenerate_constraints_passes_a_final_version(monkeypatch, release_cmd)
 
     assert "rc" not in str(calls["version"])
     assert calls["ref"] == "v3-0-stable"
+
+
[email protected]("override", [None, False])
+def test_regenerate_constraints_passes_the_override_through(monkeypatch, 
release_cmd, override):
+    calls: dict[str, object] = {}
+    monkeypatch.setattr(release_cmd, "publish_constraints", lambda **kw: 
calls.update(kw))
+
+    release_cmd.regenerate_constraints("3.3.0", override)
+
+    assert calls["fail_on_pending_provider_candidates"] is override
+
+
+def 
test_decide_override_does_not_prompt_without_pending_candidates(monkeypatch, 
release_cmd):
+    monkeypatch.setattr(release_cmd, "find_pending_provider_candidates", 
lambda: [])
+
+    def fail_if_called(*a, **k):
+        raise AssertionError("confirm_action must not be called when nothing 
is pending")
+
+    monkeypatch.setattr(release_cmd, "confirm_action", fail_if_called)
+
+    assert release_cmd.decide_pending_candidates_override() is None
+
+
+def test_decide_override_pins_released_providers_when_accepted(monkeypatch, 
release_cmd):
+    monkeypatch.setattr(
+        release_cmd,
+        "find_pending_provider_candidates",
+        lambda: [("apache-airflow-providers-standard", "2.0.0rc1")],
+    )
+    monkeypatch.setattr(release_cmd, "confirm_action", lambda *a, **k: True)
+
+    assert release_cmd.decide_pending_candidates_override() is False
+
+
+def test_decide_override_stops_the_release_when_declined(monkeypatch, 
release_cmd):
+    """Keeping the guard on would only fail after the PyPI upload, so 
declining stops here."""
+    monkeypatch.setattr(
+        release_cmd,
+        "find_pending_provider_candidates",
+        lambda: [("apache-airflow-providers-standard", "2.0.0rc1")],
+    )
+    monkeypatch.setattr(release_cmd, "confirm_action", lambda *a, **k: False)
+
+    with pytest.raises(SystemExit):
+        release_cmd.decide_pending_candidates_override()
+
+
+class _StopRelease(Exception):
+    pass
+
+
+def test_airflow_release_decides_override_before_touching_svn(monkeypatch, 
release_cmd):
+    """The override is settled before the first irreversible step, not after 
the PyPI upload."""
+    calls: list[str] = []
+    monkeypatch.setattr(release_cmd, "confirm_action", lambda *a, **kw: True)
+    monkeypatch.setattr(release_cmd, "run_command", lambda *a, **kw: None)
+    monkeypatch.setattr(release_cmd.os, "chdir", lambda *a: None)
+    monkeypatch.setattr(
+        release_cmd, "decide_pending_candidates_override", lambda: 
calls.append("override") or None
+    )
+
+    def stop_at_clone(*a, **kw):
+        calls.append("clone")
+        raise _StopRelease
+
+    monkeypatch.setattr(release_cmd, "clone_asf_repo", stop_at_clone)
+
+    with pytest.raises(_StopRelease):
+        release_cmd.airflow_release.callback("3.4.0", None)
+
+    assert calls == ["override", "clone"]
diff --git a/dev/breeze/tests/test_release_constraints.py 
b/dev/breeze/tests/test_release_constraints.py
index 84e6b2144a9..13e61664940 100644
--- a/dev/breeze/tests/test_release_constraints.py
+++ b/dev/breeze/tests/test_release_constraints.py
@@ -16,7 +16,10 @@
 # under the License.
 from __future__ import annotations
 
+from unittest import mock
+
 import pytest
+import requests
 
 from airflow_breeze.utils import release_constraints
 
@@ -90,3 +93,65 @@ def 
test_the_prompt_names_the_stage_the_version_implies(monkeypatch, version, ex
     release_constraints.publish_constraints(version=version, ref="v3-1-stable")
 
     assert expected_stage in prompts[0]
+
+
[email protected](
+    ("override", "expected"),
+    [
+        pytest.param(None, "", id="derive-from-version"),
+        pytest.param(False, "false", id="pin-released-providers"),
+        pytest.param(True, "true", id="force-guard"),
+    ],
+)
+def test_override_is_passed_to_the_workflow_input(triggered, override, 
expected):
+    release_constraints.publish_constraints(
+        version="3.4.0", ref="v3-4-stable", 
fail_on_pending_provider_candidates=override
+    )
+
+    assert triggered[0]["fail-on-pending-provider-candidates"] == expected
+
+
[email protected](
+    ("releases", "expected"),
+    [
+        pytest.param({"1.0.0": [{}], "2.0.0rc1": [{}]}, "2.0.0rc1", 
id="candidate-newest"),
+        pytest.param({"1.0.0rc1": [{}], "1.0.0": [{}]}, "1.0.0", 
id="candidate-released"),
+        pytest.param({"1.0.0": [{}], "2.0.0rc1": [{"yanked": True}]}, "1.0.0", 
id="candidate-yanked"),
+        pytest.param({"1.0.0": [{}], "2.0.0": []}, "1.0.0", id="no-files"),
+        pytest.param({"not-a-version": [{}]}, None, id="nothing-usable"),
+    ],
+)
[email protected](requests, "get", autospec=True)
+def test_find_newest_published_version(mock_get, releases, expected):
+    mock_get.return_value.status_code = 200
+    mock_get.return_value.json.return_value = {"releases": releases}
+
+    assert 
release_constraints._find_newest_published_version("apache-airflow-providers-x")
 == expected
+
+
[email protected](requests, "get", autospec=True)
+def test_find_newest_published_version_unpublished(mock_get):
+    mock_get.return_value.status_code = 404
+
+    assert 
release_constraints._find_newest_published_version("apache-airflow-providers-x")
 is None
+
+
[email protected]("airflow_breeze.utils.provider_dependencies.get_provider_dependencies",
 autospec=True)
+def 
test_find_pending_provider_candidates_checks_only_ready_providers(mock_deps, 
monkeypatch):
+    mock_deps.return_value = {
+        "amazon": {"state": "ready"},
+        "apache.hive": {"state": "ready"},
+        "google": {"state": "ready"},
+        "old": {"state": "suspended"},
+        "new": {"state": "not-ready"},
+    }
+    newest = {
+        "apache-airflow-providers-amazon": "10.0.0rc1",
+        "apache-airflow-providers-apache-hive": "9.0.0",
+        "apache-airflow-providers-google": None,
+    }
+    monkeypatch.setattr(release_constraints, "_find_newest_published_version", 
newest.__getitem__)
+
+    assert release_constraints.find_pending_provider_candidates() == [
+        ("apache-airflow-providers-amazon", "10.0.0rc1")
+    ]
diff --git a/dev/breeze/tests/test_workflow_commands.py 
b/dev/breeze/tests/test_workflow_commands.py
index 36c81673eb8..2170356ceac 100644
--- a/dev/breeze/tests/test_workflow_commands.py
+++ b/dev/breeze/tests/test_workflow_commands.py
@@ -30,6 +30,7 @@ from airflow_breeze.commands.workflow_commands import (
     get_lines_added_to_site_index,
     workflow_run_mirror_base_images,
     workflow_run_publish,
+    workflow_run_release_constraints,
     workflow_run_sync_staging_to_main,
     workflow_run_upgrade_check,
 )
@@ -446,3 +447,23 @@ def 
test_upgrade_check_offers_only_branches_with_an_upgrade_workflow(mock_trigge
 
     assert result.exit_code == 2
     mock_trigger.assert_not_called()
+
+
[email protected](
+    ("args", "expected"),
+    [
+        pytest.param([], "", id="unset"),
+        pytest.param(["--fail-on-pending-provider-candidates", "false"], 
"false", id="false"),
+        pytest.param(["--fail-on-pending-provider-candidates", "true"], 
"true", id="true"),
+    ],
+)
+@patch("airflow_breeze.commands.workflow_commands.trigger_workflow_and_monitor",
 autospec=True)
+def test_release_constraints_passes_the_override_flag(mock_trigger, args, 
expected):
+    result = CliRunner().invoke(
+        workflow_run_release_constraints,
+        ["--version", "3.4.0", "--ref", "v3-4-stable", *args],
+        catch_exceptions=False,
+    )
+
+    assert result.exit_code == 0
+    assert 
mock_trigger.call_args.kwargs["fail-on-pending-provider-candidates"] == expected

Reply via email to