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

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


The following commit(s) were added to refs/heads/v3-2-test by this push:
     new 40ee2c7450a Prefer gh auth over GitHub tokens for Breeze (#66255) 
(#67078)
40ee2c7450a is described below

commit 40ee2c7450afa3948fa99b6cf8faa824b46897dc
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon May 18 02:50:49 2026 +0200

    Prefer gh auth over GitHub tokens for Breeze (#66255) (#67078)
    
    * Breeze: Prefer gh auth over GitHub tokens
    
    * Respect dry run functionality
    
    * Add Breeze GitHub helper edge-case tests
    
    * Clarify Breeze GitHub token precedence
    
    * Simplify release management GitHub token resolution
    
    * Clarify GITHUB_TOKEN console print
    
    (cherry picked from commit 7fef6c1b84fe33a3033df95e3408169258f5156a)
    
    Co-authored-by: Paul <[email protected]>
---
 dev/breeze/doc/images/output-commands.svg          |  22 ++-
 dev/breeze/doc/images/output_run.txt               |   2 +-
 dev/breeze/doc/images/output_shell.txt             |   2 +-
 dev/breeze/doc/images/output_start-airflow.txt     |   2 +-
 .../src/airflow_breeze/commands/ci_commands.py     |  14 +-
 .../src/airflow_breeze/commands/issues_commands.py |  15 +-
 .../commands/release_management_commands.py        |  13 +-
 .../airflow_breeze/commands/workflow_commands.py   |  14 +-
 .../src/airflow_breeze/utils/gh_workflow_utils.py  |   8 +-
 dev/breeze/src/airflow_breeze/utils/github.py      |  81 ++++++++++
 .../airflow_breeze/utils/provider_dependencies.py  |  17 ++-
 dev/breeze/tests/test_github_utils.py              | 165 +++++++++++++++++++++
 12 files changed, 291 insertions(+), 64 deletions(-)

diff --git a/dev/breeze/doc/images/output-commands.svg 
b/dev/breeze/doc/images/output-commands.svg
index e5762bf56c8..9f463bb1964 100644
--- a/dev/breeze/doc/images/output-commands.svg
+++ b/dev/breeze/doc/images/output-commands.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 1482 2563.2" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 1482 2636.3999999999996" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -43,7 +43,7 @@
 
     <defs>
     <clipPath id="breeze-help-clip-terminal">
-      <rect x="0" y="0" width="1463.0" height="2512.2" />
+      <rect x="0" y="0" width="1463.0" height="2585.3999999999996" />
     </clipPath>
     <clipPath id="breeze-help-line-0">
     <rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -351,9 +351,18 @@
 <clipPath id="breeze-help-line-101">
     <rect x="0" y="2465.9" width="1464" height="24.65"/>
             </clipPath>
+<clipPath id="breeze-help-line-102">
+    <rect x="0" y="2490.3" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-help-line-103">
+    <rect x="0" y="2514.7" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-help-line-104">
+    <rect x="0" y="2539.1" 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="2561.2" rx="8"/><text 
class="breeze-help-title" fill="#c5c8c6" text-anchor="middle" x="740" 
y="27">Breeze&#160;commands</text>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="2634.4" rx="8"/><text 
class="breeze-help-title" fill="#c5c8c6" text-anchor="middle" x="740" 
y="27">Breeze&#160;commands</text>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
@@ -463,9 +472,12 @@
 </text><text class="breeze-help-r5" x="0" y="2386.8" textLength="24.4" 
clip-path="url(#breeze-help-line-97)">╭─</text><text class="breeze-help-r5" 
x="24.4" y="2386.8" textLength="207.4" 
clip-path="url(#breeze-help-line-97)">&#160;Issues&#160;commands&#160;</text><text
 class="breeze-help-r5" x="231.8" y="2386.8" textLength="1207.8" 
clip-path="url(#breeze-help-line-97)">───────────────────────────────────────────────────────────────────────────────────────────────────</text><text
 class="br [...]
 </text><text class="breeze-help-r5" x="0" y="2411.2" textLength="12.2" 
clip-path="url(#breeze-help-line-98)">│</text><text class="breeze-help-r4" 
x="24.4" y="2411.2" textLength="231.8" 
clip-path="url(#breeze-help-line-98)">issues&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-help-r1" x="280.6" y="2411.2" textLength="1159" 
clip-path="url(#breeze-help-line-98)">Tools&#160;for&#160;managing&#160;GitHub&#160;issues.&#160;&#160;&#160;&
 [...]
 </text><text class="breeze-help-r5" x="0" y="2435.6" textLength="1464" 
clip-path="url(#breeze-help-line-99)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-help-r1" x="1464" y="2435.6" textLength="12.2" 
clip-path="url(#breeze-help-line-99)">
-</text><text class="breeze-help-r5" x="0" y="2460" textLength="24.4" 
clip-path="url(#breeze-help-line-100)">╭─</text><text class="breeze-help-r5" 
x="24.4" y="2460" textLength="195.2" 
clip-path="url(#breeze-help-line-100)">&#160;Setup&#160;commands&#160;</text><text
 class="breeze-help-r5" x="219.6" y="2460" textLength="1220" 
clip-path="url(#breeze-help-line-100)">────────────────────────────────────────────────────────────────────────────────────────────────────</text><text
 class="breeze- [...]
-</text><text class="breeze-help-r5" x="0" y="2484.4" textLength="12.2" 
clip-path="url(#breeze-help-line-101)">│</text><text class="breeze-help-r4" 
x="24.4" y="2484.4" textLength="146.4" 
clip-path="url(#breeze-help-line-101)">setup&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-help-r1" x="195.2" y="2484.4" textLength="1244.4" 
clip-path="url(#breeze-help-line-101)">Tools&#160;that&#160;developers&#160;can&#160;use&#160;to&#160;configure&#160;Breeze&#160;&#160;&#160;&#
 [...]
+</text><text class="breeze-help-r5" x="0" y="2460" textLength="24.4" 
clip-path="url(#breeze-help-line-100)">╭─</text><text class="breeze-help-r5" 
x="24.4" y="2460" textLength="158.6" 
clip-path="url(#breeze-help-line-100)">&#160;PR&#160;commands&#160;</text><text 
class="breeze-help-r5" x="183" y="2460" textLength="1256.6" 
clip-path="url(#breeze-help-line-100)">───────────────────────────────────────────────────────────────────────────────────────────────────────</text><text
 class="breeze- [...]
+</text><text class="breeze-help-r5" x="0" y="2484.4" textLength="12.2" 
clip-path="url(#breeze-help-line-101)">│</text><text class="breeze-help-r4" 
x="24.4" y="2484.4" textLength="85.4" 
clip-path="url(#breeze-help-line-101)">pr&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-help-r1" x="134.2" y="2484.4" textLength="1305.4" 
clip-path="url(#breeze-help-line-101)">Tools&#160;for&#160;managing&#160;GitHub&#160;pull&#160;requests.&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&
 [...]
 </text><text class="breeze-help-r5" x="0" y="2508.8" textLength="1464" 
clip-path="url(#breeze-help-line-102)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-help-r1" x="1464" y="2508.8" textLength="12.2" 
clip-path="url(#breeze-help-line-102)">
+</text><text class="breeze-help-r5" x="0" y="2533.2" textLength="24.4" 
clip-path="url(#breeze-help-line-103)">╭─</text><text class="breeze-help-r5" 
x="24.4" y="2533.2" textLength="195.2" 
clip-path="url(#breeze-help-line-103)">&#160;Setup&#160;commands&#160;</text><text
 class="breeze-help-r5" x="219.6" y="2533.2" textLength="1220" 
clip-path="url(#breeze-help-line-103)">────────────────────────────────────────────────────────────────────────────────────────────────────</text><text
 class="b [...]
+</text><text class="breeze-help-r5" x="0" y="2557.6" textLength="12.2" 
clip-path="url(#breeze-help-line-104)">│</text><text class="breeze-help-r4" 
x="24.4" y="2557.6" textLength="146.4" 
clip-path="url(#breeze-help-line-104)">setup&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-help-r1" x="195.2" y="2557.6" textLength="1244.4" 
clip-path="url(#breeze-help-line-104)">Tools&#160;that&#160;developers&#160;can&#160;use&#160;to&#160;configure&#160;Breeze&#160;&#160;&#160;&#
 [...]
+</text><text class="breeze-help-r5" x="0" y="2582" textLength="1464" 
clip-path="url(#breeze-help-line-105)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-help-r1" x="1464" y="2582" textLength="12.2" 
clip-path="url(#breeze-help-line-105)">
 </text>
     </g>
     </g>
diff --git a/dev/breeze/doc/images/output_run.txt 
b/dev/breeze/doc/images/output_run.txt
index 5d3b86b5069..c107633ee5a 100644
--- a/dev/breeze/doc/images/output_run.txt
+++ b/dev/breeze/doc/images/output_run.txt
@@ -1 +1 @@
-54d7d635ab887d0f556a9cebcab7f63f
+d4841617ec2b8d96643f6695adcb68d6
diff --git a/dev/breeze/doc/images/output_shell.txt 
b/dev/breeze/doc/images/output_shell.txt
index 3544feee31e..4e908c8430c 100644
--- a/dev/breeze/doc/images/output_shell.txt
+++ b/dev/breeze/doc/images/output_shell.txt
@@ -1 +1 @@
-95bba676df9c9dccc5b0798fbc269f6e
+a35521c64446afe2657126bec1132254
diff --git a/dev/breeze/doc/images/output_start-airflow.txt 
b/dev/breeze/doc/images/output_start-airflow.txt
index 81c11faa429..2811d41b26b 100644
--- a/dev/breeze/doc/images/output_start-airflow.txt
+++ b/dev/breeze/doc/images/output_start-airflow.txt
@@ -1 +1 @@
-a76fae2707fcab0cc46a68f1186c6a08
+ed465431e2702e05546a60b46dd16908
diff --git a/dev/breeze/src/airflow_breeze/commands/ci_commands.py 
b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
index f05e6fa04c2..1e3746f410f 100644
--- a/dev/breeze/src/airflow_breeze/commands/ci_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
@@ -58,6 +58,7 @@ from airflow_breeze.utils.docker_command_utils import (
     fix_ownership_using_docker,
     perform_environment_checks,
 )
+from airflow_breeze.utils.github import retrieve_github_token
 from airflow_breeze.utils.path_utils import AIRFLOW_HOME_PATH, 
AIRFLOW_ROOT_PATH
 from airflow_breeze.utils.run_utils import run_command
 
@@ -776,16 +777,7 @@ def upgrade(
 
     console_print("[info]Running upgrade of important CI environment.[/]")
 
-    # Resolve GitHub token: prefer --github-token / GITHUB_TOKEN env var, fall 
back to gh CLI
-    if not github_token:
-        gh_token_result = run_command(
-            ["gh", "auth", "token"],
-            capture_output=True,
-            text=True,
-            check=False,
-        )
-        if gh_token_result.returncode == 0 and gh_token_result.stdout.strip():
-            github_token = gh_token_result.stdout.strip()
+    github_token = retrieve_github_token(github_token)
 
     # Create a copy of the environment to pass to commands
     command_env = os.environ.copy()
@@ -795,7 +787,7 @@ def upgrade(
         console_print("[success]GitHub token set in environment.[/]")
     else:
         console_print(
-            "[warning]Could not retrieve GitHub token from --github-token or 
gh CLI. "
+            "[warning]Could not retrieve GitHub token from --github-token, gh 
CLI, or token env. "
             "Commands may fail if they require authentication.[/]"
         )
 
diff --git a/dev/breeze/src/airflow_breeze/commands/issues_commands.py 
b/dev/breeze/src/airflow_breeze/commands/issues_commands.py
index 1b5316c7ada..7acfe45cfaf 100644
--- a/dev/breeze/src/airflow_breeze/commands/issues_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/issues_commands.py
@@ -31,7 +31,7 @@ from airflow_breeze.commands.common_options import (
 from airflow_breeze.utils.click_utils import BreezeGroup
 from airflow_breeze.utils.confirm import Answer, user_confirm
 from airflow_breeze.utils.console import console_print
-from airflow_breeze.utils.run_utils import run_command
+from airflow_breeze.utils.github import retrieve_github_token
 from airflow_breeze.utils.shared_options import get_dry_run
 
 
@@ -42,18 +42,7 @@ def issues_group():
 
 def _resolve_github_token(github_token: str | None) -> str | None:
     """Resolve GitHub token from option, environment, or gh CLI."""
-    if github_token:
-        return github_token
-    gh_token_result = run_command(
-        ["gh", "auth", "token"],
-        capture_output=True,
-        text=True,
-        check=False,
-        dry_run_override=False,
-    )
-    if gh_token_result.returncode == 0:
-        return gh_token_result.stdout.strip()
-    return None
+    return retrieve_github_token(github_token)
 
 
 def _get_collaborator_logins(repo) -> set[str]:
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 390a394b606..18f277f31a1 100644
--- a/dev/breeze/src/airflow_breeze/commands/release_management_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/release_management_commands.py
@@ -137,6 +137,7 @@ from airflow_breeze.utils.docker_command_utils import (
     fix_ownership_using_docker,
     perform_environment_checks,
 )
+from airflow_breeze.utils.github import retrieve_github_token
 from airflow_breeze.utils.helm_chart_utils import chart_version
 from airflow_breeze.utils.packages import (
     PackageSuspendedException,
@@ -2666,16 +2667,7 @@ def generate_issue_content_providers(
             all_prs.update(prs)
             provider_prs[provider_id] = filtered_prs
             all_retrieved_prs.update(provider_prs[provider_id])
-        if not github_token:
-            # Get GitHub token from gh CLI and set it in environment copy
-            gh_token_result = run_command(
-                ["gh", "auth", "token"],
-                capture_output=True,
-                text=True,
-                check=False,
-            )
-            if gh_token_result.returncode == 0:
-                github_token = gh_token_result.stdout.strip()
+        github_token = retrieve_github_token(github_token)
         g = Github(github_token)
         repo = g.get_repo("apache/airflow")
         pull_requests: dict[int, PullRequest.PullRequest | Issue.Issue] = {}
@@ -4164,6 +4156,7 @@ def generate_issue_content(
         excluded_prs = []
     prs = [pr for pr in change_prs if pr is not None and pr not in 
excluded_prs]
 
+    github_token = retrieve_github_token(github_token) or ""
     g = Github(github_token)
     repo = g.get_repo("apache/airflow")
     pull_requests: dict[int, PullRequestOrIssue] = {}
diff --git a/dev/breeze/src/airflow_breeze/commands/workflow_commands.py 
b/dev/breeze/src/airflow_breeze/commands/workflow_commands.py
index f668833b9c8..1620a09c90b 100644
--- a/dev/breeze/src/airflow_breeze/commands/workflow_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/workflow_commands.py
@@ -28,7 +28,7 @@ from airflow_breeze.utils.click_utils import BreezeGroup
 from airflow_breeze.utils.console import console_print
 from airflow_breeze.utils.custom_param_types import BetterChoice
 from airflow_breeze.utils.gh_workflow_utils import trigger_workflow_and_monitor
-from airflow_breeze.utils.run_utils import run_command
+from airflow_breeze.utils.github import run_gh_command
 
 WORKFLOW_NAME_MAPS = {
     "publish-docs": "publish-docs-to-s3.yml",
@@ -127,24 +127,18 @@ def workflow_run_publish(
         )
         sys.exit(1)
     if os.environ.get("GITHUB_TOKEN", ""):
-        console_print("\n[warning]Your authentication will use GITHUB_TOKEN 
environment variable.")
         console_print(
-            "\nThis might not be what you want unless your token has "
-            "sufficient permissions to trigger workflows."
-        )
-        console_print(
-            "If you remove GITHUB_TOKEN, workflow_run will use the 
authentication you already "
-            "set-up with `gh auth login`.\n"
+            "\n[warning]GITHUB_TOKEN is set; Breeze will try your `gh auth 
login` first and only "
+            "use this token as a fallback. The fallback token must have 
workflow-trigger scope."
         )
     console_print(
         f"[blue]Validating ref: {ref}[/blue]",
     )
 
     if not skip_tag_validation:
-        tag_result = run_command(
+        tag_result = run_gh_command(
             ["gh", "api", f"repos/apache/airflow/git/refs/tags/{ref}"],
             capture_output=True,
-            check=False,
         )
 
         stdout = tag_result.stdout.decode("utf-8")
diff --git a/dev/breeze/src/airflow_breeze/utils/gh_workflow_utils.py 
b/dev/breeze/src/airflow_breeze/utils/gh_workflow_utils.py
index 81e55659b0f..caaa7b5ac78 100644
--- a/dev/breeze/src/airflow_breeze/utils/gh_workflow_utils.py
+++ b/dev/breeze/src/airflow_breeze/utils/gh_workflow_utils.py
@@ -25,7 +25,7 @@ from shutil import which
 
 from airflow_breeze.global_constants import MIN_GH_VERSION
 from airflow_breeze.utils.console import console_print
-from airflow_breeze.utils.run_utils import run_command
+from airflow_breeze.utils.github import run_gh_command
 
 
 def tigger_workflow(workflow_name: str, repo: str, branch: str = "main", 
**kwargs):
@@ -50,7 +50,7 @@ def tigger_workflow(workflow_name: str, repo: str, branch: 
str = "main", **kwarg
         command.extend(["-f", f"{key}={value}"])
 
     console_print(f"[blue]Running command: {' '.join(command)}[/blue]")
-    result = run_command(command, capture_output=True, check=False)
+    result = run_gh_command(command, capture_output=True)
 
     if result.returncode != 0:
         console_print(f"[red]Error running workflow: {result.stderr}[/red]")
@@ -109,7 +109,7 @@ def get_workflow_run_id(workflow_name: str, repo: str) -> 
int:
         "databaseId",
     ]
 
-    result = run_command(command, capture_output=True, check=False)
+    result = run_gh_command(command, capture_output=True)
     if result.returncode != 0:
         console_print(f"[red]Error fetching workflow run ID: 
{result.stderr}[/red]")
         sys.exit(1)
@@ -139,7 +139,7 @@ def get_workflow_run_info(run_id: str, repo: str, fields: 
str) -> dict:
     make_sure_gh_is_installed()
     command = ["gh", "run", "view", run_id, "--json", fields, "--repo", repo]
 
-    result = run_command(command, capture_output=True, check=False)
+    result = run_gh_command(command, capture_output=True)
     if result.returncode != 0:
         console_print(f"[red]Error fetching workflow run status: 
{result.stderr}[/red]")
         sys.exit(1)
diff --git a/dev/breeze/src/airflow_breeze/utils/github.py 
b/dev/breeze/src/airflow_breeze/utils/github.py
index aaba26d7f6e..748c6a157a2 100644
--- a/dev/breeze/src/airflow_breeze/utils/github.py
+++ b/dev/breeze/src/airflow_breeze/utils/github.py
@@ -18,9 +18,11 @@ from __future__ import annotations
 
 import os
 import re
+import subprocess
 import sys
 import tempfile
 import zipfile
+from collections.abc import Mapping, Sequence
 from dataclasses import dataclass, field
 from datetime import datetime, timezone
 from pathlib import Path
@@ -36,6 +38,85 @@ from airflow_breeze.utils.shared_options import get_dry_run, 
get_verbose
 if TYPE_CHECKING:
     from requests import Response
 
+GITHUB_TOKEN_ENV_VARS = ("GH_TOKEN", "GITHUB_TOKEN")
+
+
+def env_without_github_tokens(env: Mapping[str, str] | None = None) -> 
dict[str, str]:
+    """Return a copy of *env* with ambient GitHub CLI token variables 
removed."""
+    cleaned_env = dict(os.environ if env is None else env)
+    for token_env_var in GITHUB_TOKEN_ENV_VARS:
+        cleaned_env.pop(token_env_var, None)
+    return cleaned_env
+
+
+def get_github_token_from_env(env: Mapping[str, str] | None = None) -> str | 
None:
+    """Return an ambient GitHub token using the same precedence as the GitHub 
CLI."""
+    source_env = os.environ if env is None else env
+    for token_env_var in GITHUB_TOKEN_ENV_VARS:
+        token = source_env.get(token_env_var)
+        if token:
+            return token
+    return None
+
+
+def run_gh_command(
+    command: Sequence[str],
+    *,
+    retry_with_github_token: bool = True,
+    env: Mapping[str, str] | None = None,
+    **kwargs: Any,
+) -> subprocess.CompletedProcess[Any]:
+    """
+    Run a ``gh`` command using stored ``gh auth login`` credentials before 
ambient token env vars.
+
+    Locally, ``GH_TOKEN``/``GITHUB_TOKEN`` can shadow the user's normal GitHub 
CLI login. We first
+    run with those variables removed, then retry with the original environment 
only when that fails.
+    """
+    command_env = os.environ.copy() if env is None else dict(env)
+    check = kwargs.pop("check", False)
+    if get_dry_run():
+        return subprocess.CompletedProcess(command, returncode=0, stdout="", 
stderr="")
+    result = subprocess.run(command, 
env=env_without_github_tokens(command_env), check=False, **kwargs)
+    if result.returncode == 0:
+        return result
+    if not retry_with_github_token or not 
get_github_token_from_env(command_env):
+        if check:
+            result.check_returncode()
+        return result
+    return subprocess.run(command, env=command_env, check=check, **kwargs)
+
+
+def retrieve_github_token(token: str | None = None, *, env: Mapping[str, str] 
| None = None) -> str | None:
+    """
+    Resolve a GitHub token for local Breeze commands.
+
+    Non-empty token arguments are preserved when they do not match 
``GH_TOKEN`` or
+    ``GITHUB_TOKEN`` from the environment. Matching values are treated as 
ambient env input because
+    Click can populate ``--github-token`` from ``envvar="GITHUB_TOKEN"``. 
Ambient env tokens are used
+    only after trying the user's stored ``gh auth login`` credential.
+    """
+    env_token = get_github_token_from_env(env)
+    source_env = os.environ if env is None else env
+    env_tokens = {
+        source_env[token_env_var] for token_env_var in GITHUB_TOKEN_ENV_VARS 
if source_env.get(token_env_var)
+    }
+    if token and token not in env_tokens:
+        return token
+    try:
+        gh_token_result = run_gh_command(
+            ["gh", "auth", "token"],
+            capture_output=True,
+            text=True,
+            check=False,
+            retry_with_github_token=False,
+            env=env,
+        )
+    except FileNotFoundError:
+        return token or env_token
+    if gh_token_result.returncode == 0 and gh_token_result.stdout.strip():
+        return gh_token_result.stdout.strip()
+    return token or env_token
+
 
 def get_ga_output(name: str, value: Any) -> str:
     output_name = name.replace("_", "-")
diff --git a/dev/breeze/src/airflow_breeze/utils/provider_dependencies.py 
b/dev/breeze/src/airflow_breeze/utils/provider_dependencies.py
index 38e19cbf32d..998a170bf66 100644
--- a/dev/breeze/src/airflow_breeze/utils/provider_dependencies.py
+++ b/dev/breeze/src/airflow_breeze/utils/provider_dependencies.py
@@ -39,7 +39,12 @@ from airflow_breeze.global_constants import (
 )
 from airflow_breeze.utils.ci_group import ci_group
 from airflow_breeze.utils.console import console_print
-from airflow_breeze.utils.github import download_constraints_file, 
get_active_airflow_versions, get_tag_date
+from airflow_breeze.utils.github import (
+    download_constraints_file,
+    get_active_airflow_versions,
+    get_tag_date,
+    retrieve_github_token,
+)
 from airflow_breeze.utils.packages import get_provider_distributions_metadata
 from airflow_breeze.utils.path_utils import (
     AIRFLOW_PYPROJECT_TOML_FILE_PATH,
@@ -48,7 +53,6 @@ from airflow_breeze.utils.path_utils import (
     PROVIDER_DEPENDENCIES_JSON_HASH_PATH,
     PROVIDER_DEPENDENCIES_JSON_PATH,
 )
-from airflow_breeze.utils.run_utils import run_command
 from airflow_breeze.utils.shared_options import get_verbose
 
 _regenerate_provider_deps_lock = Lock()
@@ -248,12 +252,9 @@ def get_all_constraint_files_and_airflow_releases(
         shutil.rmtree(CONSTRAINTS_CACHE_PATH, ignore_errors=True)
     if not CONSTRAINTS_CACHE_PATH.exists():
         if not github_token:
-            gh_auth_command = run_command(
-                ["gh", "auth", "token"], check=False, capture_output=True, 
text=True
-            )
-            if gh_auth_command.returncode == 0:
-                console_print("\n[info]Retrieved GitHub token from gh auth 
token command[/]\n")
-                github_token = gh_auth_command.stdout.strip()
+            github_token = retrieve_github_token()
+            if github_token:
+                console_print("\n[info]Resolved GitHub token for constraints 
refresh[/]\n")
             else:
                 console_print(
                     "[error]You need to provide GITHUB_TOKEN to generate 
providers metadata.[/]\n\n"
diff --git a/dev/breeze/tests/test_github_utils.py 
b/dev/breeze/tests/test_github_utils.py
new file mode 100644
index 00000000000..142cf226bbc
--- /dev/null
+++ b/dev/breeze/tests/test_github_utils.py
@@ -0,0 +1,165 @@
+# 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 subprocess
+from unittest import mock
+
+import pytest
+
+from airflow_breeze.utils.github import (
+    env_without_github_tokens,
+    retrieve_github_token,
+    run_gh_command,
+)
+from airflow_breeze.utils.shared_options import set_dry_run
+
+
+def _completed_process(returncode: int, stdout: str = "") -> 
subprocess.CompletedProcess[str]:
+    return subprocess.CompletedProcess(args=["gh"], returncode=returncode, 
stdout=stdout, stderr="")
+
+
+def test_env_without_github_tokens_removes_ambient_token_vars(monkeypatch):
+    monkeypatch.setenv("GH_TOKEN", "gh-token")
+    monkeypatch.setenv("GITHUB_TOKEN", "github-token")
+    monkeypatch.setenv("OTHER_VAR", "kept")
+
+    cleaned_env = env_without_github_tokens()
+
+    assert "GH_TOKEN" not in cleaned_env
+    assert "GITHUB_TOKEN" not in cleaned_env
+    assert cleaned_env["OTHER_VAR"] == "kept"
+
+
[email protected]("airflow_breeze.utils.github.subprocess.run")
+def test_retrieve_github_token_prefers_clean_gh_auth_token(mock_run, 
monkeypatch):
+    monkeypatch.setenv("GH_TOKEN", "env-gh-token")
+    monkeypatch.setenv("GITHUB_TOKEN", "env-github-token")
+    mock_run.return_value = _completed_process(returncode=0, 
stdout="stored-gh-token\n")
+
+    assert retrieve_github_token() == "stored-gh-token"
+
+    mock_run.assert_called_once()
+    call_env = mock_run.call_args.kwargs["env"]
+    assert "GH_TOKEN" not in call_env
+    assert "GITHUB_TOKEN" not in call_env
+
+
[email protected]("airflow_breeze.utils.github.subprocess.run")
+def test_retrieve_github_token_falls_back_to_env_token(mock_run, monkeypatch):
+    monkeypatch.setenv("GH_TOKEN", "env-gh-token")
+    monkeypatch.setenv("GITHUB_TOKEN", "env-github-token")
+    mock_run.return_value = _completed_process(returncode=1)
+
+    assert retrieve_github_token() == "env-gh-token"
+
+
[email protected]("airflow_breeze.utils.github.subprocess.run")
+def 
test_retrieve_github_token_falls_back_to_env_token_when_gh_is_missing(mock_run, 
monkeypatch):
+    monkeypatch.setenv("GITHUB_TOKEN", "env-github-token")
+    mock_run.side_effect = FileNotFoundError
+
+    assert retrieve_github_token() == "env-github-token"
+
+
[email protected]("airflow_breeze.utils.github.subprocess.run")
+def 
test_retrieve_github_token_falls_back_to_env_token_when_gh_returns_whitespace(mock_run,
 monkeypatch):
+    monkeypatch.setenv("GITHUB_TOKEN", "env-github-token")
+    mock_run.return_value = _completed_process(returncode=0, stdout="  \n")
+
+    assert retrieve_github_token() == "env-github-token"
+
+
[email protected]("airflow_breeze.utils.github.subprocess.run")
+def test_retrieve_github_token_keeps_explicit_token(mock_run, monkeypatch):
+    monkeypatch.setenv("GITHUB_TOKEN", "env-token")
+
+    assert retrieve_github_token("explicit-token") == "explicit-token"
+
+    mock_run.assert_not_called()
+
+
[email protected]("airflow_breeze.utils.github.subprocess.run")
+def 
test_retrieve_github_token_does_not_treat_env_token_argument_as_explicit(mock_run,
 monkeypatch):
+    monkeypatch.setenv("GITHUB_TOKEN", "env-token")
+    mock_run.return_value = _completed_process(returncode=0, 
stdout="stored-gh-token\n")
+
+    assert retrieve_github_token("env-token") == "stored-gh-token"
+
+
[email protected]("airflow_breeze.utils.github.subprocess.run")
+def 
test_run_gh_command_retries_with_original_env_after_clean_env_failure(mock_run, 
monkeypatch):
+    monkeypatch.setenv("GH_TOKEN", "env-gh-token")
+    monkeypatch.setenv("GITHUB_TOKEN", "env-github-token")
+    mock_run.side_effect = [
+        _completed_process(returncode=1),
+        _completed_process(returncode=0),
+    ]
+
+    result = run_gh_command(["gh", "workflow", "run", "docs.yml"], 
capture_output=True)
+
+    assert result.returncode == 0
+    assert mock_run.call_count == 2
+    first_env = mock_run.call_args_list[0].kwargs["env"]
+    second_env = mock_run.call_args_list[1].kwargs["env"]
+    assert "GH_TOKEN" not in first_env
+    assert "GITHUB_TOKEN" not in first_env
+    assert second_env["GH_TOKEN"] == "env-gh-token"
+    assert second_env["GITHUB_TOKEN"] == "env-github-token"
+
+
[email protected]("airflow_breeze.utils.github.subprocess.run")
+def test_run_gh_command_does_not_retry_after_clean_env_success(mock_run, 
monkeypatch):
+    monkeypatch.setenv("GITHUB_TOKEN", "env-token")
+    mock_run.return_value = _completed_process(returncode=0)
+
+    result = run_gh_command(["gh", "api", "repos/apache/airflow"], 
capture_output=True)
+
+    assert result.returncode == 0
+    mock_run.assert_called_once()
+
+
[email protected]("airflow_breeze.utils.github.subprocess.run")
+def 
test_run_gh_command_raises_when_check_true_and_no_env_token_to_retry(mock_run, 
monkeypatch):
+    monkeypatch.delenv("GH_TOKEN", raising=False)
+    monkeypatch.delenv("GITHUB_TOKEN", raising=False)
+    mock_run.return_value = _completed_process(returncode=1)
+
+    with pytest.raises(subprocess.CalledProcessError) as ctx:
+        run_gh_command(["gh", "api", "repos/apache/airflow"], 
capture_output=True, check=True)
+
+    assert ctx.value.returncode == 1
+
+
[email protected]("airflow_breeze.utils.github.subprocess.run")
+def test_run_gh_command_raises_when_gh_is_missing(mock_run):
+    mock_run.side_effect = FileNotFoundError
+
+    with pytest.raises(FileNotFoundError):
+        run_gh_command(["gh", "api", "repos/apache/airflow"], 
capture_output=True)
+
+
[email protected]("airflow_breeze.utils.github.subprocess.run")
+def test_run_gh_command_skips_subprocess_in_dry_run(mock_run):
+    set_dry_run(True)
+    try:
+        result = run_gh_command(["gh", "workflow", "run", "docs.yml"], 
capture_output=True)
+    finally:
+        set_dry_run(False)
+
+    assert result.returncode == 0
+    mock_run.assert_not_called()

Reply via email to