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 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 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)"> Issues commands </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             </text><text
class="breeze-help-r1" x="280.6" y="2411.2" textLength="1159"
clip-path="url(#breeze-help-line-98)">Tools for managing GitHub issues.   &
[...]
</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)"> Setup commands </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       </text><text
class="breeze-help-r1" x="195.2" y="2484.4" textLength="1244.4"
clip-path="url(#breeze-help-line-101)">Tools that developers can use to configure Breeze   &#
[...]
+</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)"> PR commands </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     </text><text
class="breeze-help-r1" x="134.2" y="2484.4" textLength="1305.4"
clip-path="url(#breeze-help-line-101)">Tools for managing GitHub pull requests.         &
[...]
</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)"> Setup commands </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       </text><text
class="breeze-help-r1" x="195.2" y="2557.6" textLength="1244.4"
clip-path="url(#breeze-help-line-104)">Tools that developers can use to configure Breeze   &#
[...]
+</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()