From 8a2444f03c1092f0e604d6d6ef4107ebd88b30a6 Mon Sep 17 00:00:00 2001 From: RaphCodec <46251144+RaphCodec@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:26:55 -0400 Subject: [PATCH 1/3] add GitHub App Authentication as an option for DagBundles in git provider --- providers/git/pyproject.toml | 7 + .../src/airflow/providers/git/hooks/git.py | 120 +++++++- .../git/tests/unit/git/hooks/test_git.py | 281 ++++++++++++++++++ uv.lock | 7 + 4 files changed, 414 insertions(+), 1 deletion(-) diff --git a/providers/git/pyproject.toml b/providers/git/pyproject.toml index 7056a12e6b5cd..9f60ecee7956d 100644 --- a/providers/git/pyproject.toml +++ b/providers/git/pyproject.toml @@ -64,6 +64,13 @@ dependencies = [ "GitPython>=3.1.44", ] +# The optional dependencies should be modified in place in the generated file +# Any change in the dependencies is preserved when the file is regenerated +[project.optional-dependencies] +github = [ + "PyGithub>=2.1.1,<3", +] + [dependency-groups] dev = [ "apache-airflow", diff --git a/providers/git/src/airflow/providers/git/hooks/git.py b/providers/git/src/airflow/providers/git/hooks/git.py index 4ec8738758b7c..b65dbaf99b427 100644 --- a/providers/git/src/airflow/providers/git/hooks/git.py +++ b/providers/git/src/airflow/providers/git/hooks/git.py @@ -26,6 +26,7 @@ import stat import tempfile import warnings +from datetime import datetime, timedelta, timezone from typing import Any from urllib.parse import quote as urlquote @@ -53,6 +54,10 @@ class GitHook(BaseHook): * ``ssh_config_file`` — path to a custom SSH config file. * ``host_proxy_cmd`` — SSH ProxyCommand string (e.g. for bastion/jump hosts). * ``ssh_port`` — non-default SSH port. + * ``github_app_id`` — GitHub App ID used for GitHub App authentication. Requires the GitHub App + private key to be provided as a PEM-encoded key via either ``private_key`` (inline) or + ``key_file`` (path to key file). + * ``github_installation_id`` — GitHub App installation ID used for GitHub App authentication. """ conn_name_attr = "git_conn_id" @@ -80,6 +85,8 @@ def get_ui_field_behaviour(cls) -> dict[str, Any]: "ssh_config_file": "", "host_proxy_cmd": "", "ssh_port": "", + "github_app_id": "", + "github_installation_id": "", } ) }, @@ -110,6 +117,12 @@ def __init__( self.host_proxy_cmd = extra.get("host_proxy_cmd") self.ssh_port: int | None = int(extra["ssh_port"]) if extra.get("ssh_port") else None + # GitHub App Auth Options + self.github_app_id = extra.get("github_app_id") + self.github_installation_id = extra.get("github_installation_id") + self.github_app_private_key: str | None = None + self.github_app_token_exp: datetime | None = None + self.env: dict[str, str] = {} if self.key_file and self.private_key: @@ -127,6 +140,26 @@ def __init__( AirflowProviderDeprecationWarning, stacklevel=2, ) + if (self.github_app_id is not None and self.github_installation_id is None) or ( + self.github_app_id is None and self.github_installation_id is not None + ): + raise ValueError( + "Both 'github_app_id' and 'github_installation_id' must be provided to use GitHub App Authentication" + ) + if self.github_app_id is not None and self.github_installation_id is not None: + if self.key_file and not self.private_key: + with open(self.key_file, encoding="utf-8") as key_file: + self.private_key = key_file.read() + if not (self.repo_url or "").startswith(("https://", "http://")): + raise ValueError( + f"GitHub App authentication requires an HTTPS repository URL, but got: {self.repo_url!r}" + ) + # Store the PEM separately so configure_hook_env() does not treat it as an SSH key. + # Keep `private_key` populated for callers/tests that expect it to be available, + # but also keep a dedicated attribute so configure_hook_env() can avoid + # treating the GitHub App PEM as an SSH key. + self.github_app_private_key = self.private_key + self.auth_token = "" self._process_git_auth_url() _VALID_STRICT_HOST_KEY_CHECKING = frozenset({"yes", "no", "accept-new", "off", "ask"}) @@ -183,6 +216,82 @@ def _build_ssh_command(self, key_path: str | None = None) -> str: return " ".join(parts) + def _get_github_app_token(self): + try: + from github import Auth, GithubIntegration + except ImportError as exc: + raise ImportError( + "The PyGithub library is required for GitHub App authentication. Please install it with 'pip install apache-airflow-providers-git[github]'" + ) from exc + + auth = Auth.AppAuth(self.github_app_id, self.github_app_private_key) + integration = GithubIntegration(auth=auth) + access_token = integration.get_access_token(installation_id=self.github_installation_id) + github_app_token_exp = access_token.expires_at + log.info( + "Successfully obtained GitHub App installation access token (expires at: %s)", + github_app_token_exp, + ) + + return "x-access-token", access_token.token, github_app_token_exp + + def _ensure_github_app_token(self) -> None: + if self.github_app_id is None or self.github_installation_id is None: + return + + TOKEN_REFRESH_BUFFER = timedelta(minutes=5) + if ( + self.github_app_token_exp is None + or self.github_app_token_exp < datetime.now(timezone.utc) + TOKEN_REFRESH_BUFFER + ): + log.info( + "GitHub App token is missing or near expiry (expires at: %s). Refreshing token.", + self.github_app_token_exp, + ) + self.user_name, self.auth_token, self.github_app_token_exp = self._get_github_app_token() + + @contextlib.contextmanager + def _github_app_askpass_env(self): + if not self.auth_token: + yield + return + + token = shlex.quote(self.auth_token) + with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=True) as askpass_script: + askpass_script.write( + "#!/bin/sh\n" + 'case "$1" in\n' + " *Username*) echo x-access-token;;\n" + f" *Password*) echo {token};;\n" + f" *) echo {token};;\n" + "esac\n" + ) + askpass_script.flush() + os.chmod(askpass_script.name, stat.S_IRWXU) + + old_askpass = os.environ.get("GIT_ASKPASS") + old_terminal_prompt = os.environ.get("GIT_TERMINAL_PROMPT") + try: + os.environ["GIT_ASKPASS"] = askpass_script.name + os.environ["GIT_TERMINAL_PROMPT"] = "0" + self.env["GIT_ASKPASS"] = askpass_script.name + self.env["GIT_TERMINAL_PROMPT"] = "0" + yield + finally: + if old_askpass is None: + self.env.pop("GIT_ASKPASS", None) + os.environ.pop("GIT_ASKPASS", None) + else: + self.env["GIT_ASKPASS"] = old_askpass + os.environ["GIT_ASKPASS"] = old_askpass + + if old_terminal_prompt is None: + self.env.pop("GIT_TERMINAL_PROMPT", None) + os.environ.pop("GIT_TERMINAL_PROMPT", None) + else: + self.env["GIT_TERMINAL_PROMPT"] = old_terminal_prompt + os.environ["GIT_TERMINAL_PROMPT"] = old_terminal_prompt + def _process_git_auth_url(self): if not isinstance(self.repo_url, str): return @@ -240,7 +349,16 @@ def _passphrase_askpass_env(self): @contextlib.contextmanager def configure_hook_env(self): - if self.private_key: + self._ensure_github_app_token() + + if self.github_app_id is not None and self.github_installation_id is not None: + with self._github_app_askpass_env(): + yield + return + + # If a GitHub App PEM is present, it should not be treated as an SSH key + # for configuring `GIT_SSH_COMMAND`. + if self.private_key and not self.github_app_private_key: with tempfile.NamedTemporaryFile(mode="w", delete=True) as tmp_keyfile: tmp_keyfile.write(self.private_key) tmp_keyfile.flush() diff --git a/providers/git/tests/unit/git/hooks/test_git.py b/providers/git/tests/unit/git/hooks/test_git.py index 1d750ca99c37a..ce2a443ef1821 100644 --- a/providers/git/tests/unit/git/hooks/test_git.py +++ b/providers/git/tests/unit/git/hooks/test_git.py @@ -52,6 +52,12 @@ def bundle_temp_dir(tmp_path): CONN_ONLY_INLINE_KEY = "my_git_conn_only_inline_key" CONN_BOTH_PATH_INLINE = "my_git_conn_both_path_inline" CONN_NO_REPO_URL = "my_git_conn_no_repo_url" +CONN_APP_INLINE_KEY = "git_app_inline_key" +CONN_APP_ONLY_APP_ID = "git_app_only_app_id" +CONN_APP_ONLY_INSTALLATION_ID = "git_app_only_installation_id" +CONN_APP_NO_KEY = "git_app_no_key" +CONN_APP_INVALID_APP_ID = "git_app_invalid_app_id" +CONN_APP_INVALID_INSTALLATION_ID = "git_app_invalid_installation_id" @pytest.fixture @@ -123,6 +129,85 @@ def setup_connections(self, create_connection_without_db): }, ) ) + create_connection_without_db( + Connection( + conn_id=CONN_BOTH_PATH_INLINE, + host="path/to/repo", + conn_type="git", + extra={ + "key_file": "path/to/key", + "private_key": "inline_key", + }, + ) + ) + create_connection_without_db( + Connection( + conn_id="my_git_conn_strict", + host=AIRFLOW_GIT, + conn_type="git", + extra='{"key_file": "/files/pkey.pem", "strict_host_key_checking": "yes"}', + ) + ) + create_connection_without_db( + Connection( + conn_id=CONN_APP_INLINE_KEY, + host=AIRFLOW_HTTPS_URL, + conn_type="git", + extra={ + "github_app_id": "12345", + "github_installation_id": "67890", + "private_key": "inline_pem_key", + }, + ) + ) + create_connection_without_db( + Connection( + conn_id=CONN_APP_ONLY_APP_ID, + host=AIRFLOW_HTTPS_URL, + conn_type="git", + extra={"github_app_id": "12345"}, + ) + ) + create_connection_without_db( + Connection( + conn_id=CONN_APP_ONLY_INSTALLATION_ID, + host=AIRFLOW_HTTPS_URL, + conn_type="git", + extra={"github_installation_id": "67890"}, + ) + ) + create_connection_without_db( + Connection( + conn_id=CONN_APP_NO_KEY, + host=AIRFLOW_HTTPS_URL, + conn_type="git", + extra={"github_app_id": "12345", "github_installation_id": "67890"}, + ) + ) + create_connection_without_db( + Connection( + conn_id=CONN_APP_INVALID_APP_ID, + host=AIRFLOW_HTTPS_URL, + conn_type="git", + extra={ + "github_app_id": "not_an_int", + "github_installation_id": "67890", + "private_key": "inline_pem_key", + }, + ) + ) + create_connection_without_db( + Connection( + conn_id=CONN_APP_INVALID_INSTALLATION_ID, + host=AIRFLOW_HTTPS_URL, + conn_type="git", + extra={ + "github_app_id": "12345", + "github_installation_id": "not_an_int", + "private_key": "inline_pem_key", + }, + ) + ) @pytest.mark.parametrize( ("conn_id", "hook_kwargs", "expected_repo_url", "warns_on_default"), @@ -414,3 +499,199 @@ def test_passphrase_askpass_cleaned_up(self, create_connection_without_db): assert os.path.exists(askpass_path) # Both the askpass script and the temp key file should be cleaned up assert not os.path.exists(askpass_path) + + # --- GitHub App auth tests --- + + def test_only_app_id_without_installation_id_raises(self): + with pytest.raises( + AirflowException, match="Both 'github_app_id' and 'github_installation_id' must be provided" + ): + GitHook(git_conn_id=CONN_APP_ONLY_APP_ID) + + def test_only_installation_id_without_app_id_raises(self): + with pytest.raises( + AirflowException, + match="Both 'github_app_id' and 'github_installation_id' must be provided", + ): + GitHook(git_conn_id=CONN_APP_ONLY_INSTALLATION_ID) + + def test_app_id_and_installation_id_without_key_raises(self): + with pytest.raises( + AirflowException, + match="Missing inline private_key or key_file for GitHub App Auth", + ): + GitHook(git_conn_id=CONN_APP_NO_KEY) + + def test_app_auth_with_key_file_reads_file(self, create_connection_without_db, tmp_path, monkeypatch): + key_file = tmp_path / "app_key.pem" + key_file.write_text("file_pem_key_content") + create_connection_without_db( + Connection( + conn_id="git_app_key_file", + host=AIRFLOW_HTTPS_URL, + conn_type="git", + extra={ + "github_app_id": "12345", + "github_installation_id": "67890", + "key_file": str(key_file), + }, + ) + ) + from datetime import datetime, timedelta, timezone + + mock_expiry = datetime.now(timezone.utc) + timedelta(hours=1) + monkeypatch.setattr( + "airflow.providers.git.hooks.git.GitHook._get_github_app_token", + lambda self: ("x-access-token", "ghs_test_token", mock_expiry), + ) + hook = GitHook(git_conn_id="git_app_key_file") + + assert hook.private_key == "file_pem_key_content" + + def test_app_auth_with_missing_key_file_raises(self, create_connection_without_db): + create_connection_without_db( + Connection( + conn_id="git_app_missing_key_file", + host=AIRFLOW_HTTPS_URL, + conn_type="git", + extra={ + "github_app_id": "12345", + "github_installation_id": "67890", + "key_file": "/nonexistent/path/key.pem", + }, + ) + ) + with pytest.raises(AirflowException, match="Failed to read GitHub App private key file"): + GitHook(git_conn_id="git_app_missing_key_file") + + def test_app_auth_defers_token_fetch(self, monkeypatch): + """GitHub App token is not fetched in __init__, only on configure_hook_env.""" + from datetime import datetime, timedelta, timezone + + mock_called = [] + + def mock_get_token(self): + mock_called.append(True) + return ("x-access-token", "ghs_test_token", datetime.now(timezone.utc) + timedelta(hours=1)) + + monkeypatch.setattr( + "airflow.providers.git.hooks.git.GitHook._get_github_app_token", + mock_get_token, + ) + # __init__ should NOT call _get_github_app_token + hook = GitHook(git_conn_id=CONN_APP_INLINE_KEY) + assert len(mock_called) == 0 + assert hook.auth_token == "" + assert hook.github_app_id == "12345" + assert hook.github_installation_id == "67890" + + # First call to configure_hook_env should trigger the token fetch + with hook.configure_hook_env(): + assert len(mock_called) == 1 + assert hook.auth_token == "ghs_test_token" + assert hook.user_name == "x-access-token" + + def test_app_auth_success_stores_app_id_and_installation_id(self): + """App ID and installation ID are stored at __init__ time.""" + hook = GitHook(git_conn_id=CONN_APP_INLINE_KEY) + assert hook.github_app_id == "12345" + assert hook.github_installation_id == "67890" + + @pytest.mark.parametrize( + ("app_id", "installation_id"), + [ + ("12345", "67890"), + (12345, 67890), + ], + ) + def test_app_id_and_installation_id_are_stored_as_provided( + self, app_id, installation_id, create_connection_without_db, monkeypatch + ): + from datetime import datetime, timedelta, timezone + + create_connection_without_db( + Connection( + conn_id="git_app_int_check", + host=AIRFLOW_HTTPS_URL, + conn_type="git", + extra={ + "github_app_id": app_id, + "github_installation_id": installation_id, + "private_key": "inline_pem_key", + }, + ) + ) + monkeypatch.setattr( + "airflow.providers.git.hooks.git.GitHook._get_github_app_token", + lambda self: ("x-access-token", "token", datetime.now(timezone.utc) + timedelta(hours=1)), + ) + hook = GitHook(git_conn_id="git_app_int_check") + assert hook.github_app_id == app_id + assert hook.github_installation_id == installation_id + + def test_github_app_token_refresh_near_expiry(self, monkeypatch): + """Token is refreshed when near expiry during configure_hook_env.""" + from datetime import datetime, timedelta, timezone + + mock_get_token_call_count = [0] + + def mock_get_token(self): + mock_get_token_call_count[0] += 1 + # First call returns token expiring in 3 minutes + if mock_get_token_call_count[0] == 1: + return ( + "x-access-token", + f"token_{mock_get_token_call_count[0]}", + datetime.now(timezone.utc) + timedelta(minutes=3), + ) + # Second call (refresh) returns token expiring in 1 hour + return ( + "x-access-token", + f"token_{mock_get_token_call_count[0]}", + datetime.now(timezone.utc) + timedelta(hours=1), + ) + + monkeypatch.setattr( + "airflow.providers.git.hooks.git.GitHook._get_github_app_token", + mock_get_token, + ) + hook = GitHook(git_conn_id=CONN_APP_INLINE_KEY) + assert mock_get_token_call_count[0] == 0 # No call in __init__ + + # First configure_hook_env triggers first token fetch + with hook.configure_hook_env(): + assert mock_get_token_call_count[0] == 1 + assert hook.auth_token == "token_1" + + # Second configure_hook_env triggers refresh (token near expiry) + with hook.configure_hook_env(): + assert mock_get_token_call_count[0] == 2 + assert hook.auth_token == "token_2" + + def test_github_app_integration_call_shape(self, monkeypatch): + """Verify GithubIntegration is called with correct arguments.""" + from datetime import datetime, timedelta, timezone + from unittest import mock + + mock_integration = mock.MagicMock() + mock_access_token = mock.MagicMock() + mock_access_token.token = "ghs_test_token" + mock_access_token.expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + mock_integration.get_access_token.return_value = mock_access_token + + import sys + from types import SimpleNamespace + + fake_github = SimpleNamespace( + Auth=SimpleNamespace(AppAuth=lambda app_id, key: "auth"), + GithubIntegration=lambda auth: mock_integration, + ) + monkeypatch.setitem(sys.modules, "github", fake_github) + + hook = GitHook(git_conn_id=CONN_APP_INLINE_KEY) + with hook.configure_hook_env(): + # Verify get_access_token was called with installation_id kwarg + assert mock_integration.get_access_token.call_count == 1 + _, kwargs = mock_integration.get_access_token.call_args + assert "installation_id" in kwargs + assert str(kwargs["installation_id"]) == "67890" diff --git a/uv.lock b/uv.lock index bd7b5041a689b..f2084ae4ddff0 100644 --- a/uv.lock +++ b/uv.lock @@ -5398,6 +5398,11 @@ dependencies = [ { name = "gitpython" }, ] +[package.optional-dependencies] +github = [ + { name = "pygithub" }, +] + [package.dev-dependencies] dev = [ { name = "apache-airflow" }, @@ -5414,7 +5419,9 @@ requires-dist = [ { name = "apache-airflow", editable = "." }, { name = "apache-airflow-providers-common-compat", editable = "providers/common/compat" }, { name = "gitpython", specifier = ">=3.1.44" }, + { name = "pygithub", marker = "extra == 'github'", specifier = ">=2.1.1,<3" }, ] +provides-extras = ["github"] [package.metadata.requires-dev] dev = [ From 9debfbf945684390561bc20ed389b954bd063ee8 Mon Sep 17 00:00:00 2001 From: Raphael Date: Sat, 4 Jul 2026 16:37:58 -0400 Subject: [PATCH 2/3] update github app tests for git provider --- .../git/tests/unit/git/hooks/test_git.py | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/providers/git/tests/unit/git/hooks/test_git.py b/providers/git/tests/unit/git/hooks/test_git.py index ce2a443ef1821..6e6e7ee34520e 100644 --- a/providers/git/tests/unit/git/hooks/test_git.py +++ b/providers/git/tests/unit/git/hooks/test_git.py @@ -504,23 +504,23 @@ def test_passphrase_askpass_cleaned_up(self, create_connection_without_db): def test_only_app_id_without_installation_id_raises(self): with pytest.raises( - AirflowException, match="Both 'github_app_id' and 'github_installation_id' must be provided" + ValueError, match="Both 'github_app_id' and 'github_installation_id' must be provided" ): GitHook(git_conn_id=CONN_APP_ONLY_APP_ID) def test_only_installation_id_without_app_id_raises(self): with pytest.raises( - AirflowException, + ValueError, match="Both 'github_app_id' and 'github_installation_id' must be provided", ): GitHook(git_conn_id=CONN_APP_ONLY_INSTALLATION_ID) - def test_app_id_and_installation_id_without_key_raises(self): - with pytest.raises( - AirflowException, - match="Missing inline private_key or key_file for GitHub App Auth", - ): - GitHook(git_conn_id=CONN_APP_NO_KEY) + def test_app_id_and_installation_id_without_key_does_not_raise_on_init(self): + hook = GitHook(git_conn_id=CONN_APP_NO_KEY) + + assert hook.github_app_id == "12345" + assert hook.github_installation_id == "67890" + assert hook.github_app_private_key is None def test_app_auth_with_key_file_reads_file(self, create_connection_without_db, tmp_path, monkeypatch): key_file = tmp_path / "app_key.pem" @@ -544,7 +544,8 @@ def test_app_auth_with_key_file_reads_file(self, create_connection_without_db, t "airflow.providers.git.hooks.git.GitHook._get_github_app_token", lambda self: ("x-access-token", "ghs_test_token", mock_expiry), ) - hook = GitHook(git_conn_id="git_app_key_file") + with pytest.warns(AirflowProviderDeprecationWarning, match="accept-new"): + hook = GitHook(git_conn_id="git_app_key_file") assert hook.private_key == "file_pem_key_content" @@ -561,8 +562,9 @@ def test_app_auth_with_missing_key_file_raises(self, create_connection_without_d }, ) ) - with pytest.raises(AirflowException, match="Failed to read GitHub App private key file"): - GitHook(git_conn_id="git_app_missing_key_file") + with pytest.warns(AirflowProviderDeprecationWarning, match="accept-new"): + with pytest.raises(FileNotFoundError): + GitHook(git_conn_id="git_app_missing_key_file") def test_app_auth_defers_token_fetch(self, monkeypatch): """GitHub App token is not fetched in __init__, only on configure_hook_env.""" @@ -579,7 +581,8 @@ def mock_get_token(self): mock_get_token, ) # __init__ should NOT call _get_github_app_token - hook = GitHook(git_conn_id=CONN_APP_INLINE_KEY) + with pytest.warns(AirflowProviderDeprecationWarning, match="accept-new"): + hook = GitHook(git_conn_id=CONN_APP_INLINE_KEY) assert len(mock_called) == 0 assert hook.auth_token == "" assert hook.github_app_id == "12345" @@ -593,7 +596,8 @@ def mock_get_token(self): def test_app_auth_success_stores_app_id_and_installation_id(self): """App ID and installation ID are stored at __init__ time.""" - hook = GitHook(git_conn_id=CONN_APP_INLINE_KEY) + with pytest.warns(AirflowProviderDeprecationWarning, match="accept-new"): + hook = GitHook(git_conn_id=CONN_APP_INLINE_KEY) assert hook.github_app_id == "12345" assert hook.github_installation_id == "67890" @@ -625,7 +629,8 @@ def test_app_id_and_installation_id_are_stored_as_provided( "airflow.providers.git.hooks.git.GitHook._get_github_app_token", lambda self: ("x-access-token", "token", datetime.now(timezone.utc) + timedelta(hours=1)), ) - hook = GitHook(git_conn_id="git_app_int_check") + with pytest.warns(AirflowProviderDeprecationWarning, match="accept-new"): + hook = GitHook(git_conn_id="git_app_int_check") assert hook.github_app_id == app_id assert hook.github_installation_id == installation_id @@ -655,7 +660,8 @@ def mock_get_token(self): "airflow.providers.git.hooks.git.GitHook._get_github_app_token", mock_get_token, ) - hook = GitHook(git_conn_id=CONN_APP_INLINE_KEY) + with pytest.warns(AirflowProviderDeprecationWarning, match="accept-new"): + hook = GitHook(git_conn_id=CONN_APP_INLINE_KEY) assert mock_get_token_call_count[0] == 0 # No call in __init__ # First configure_hook_env triggers first token fetch @@ -688,7 +694,8 @@ def test_github_app_integration_call_shape(self, monkeypatch): ) monkeypatch.setitem(sys.modules, "github", fake_github) - hook = GitHook(git_conn_id=CONN_APP_INLINE_KEY) + with pytest.warns(AirflowProviderDeprecationWarning, match="accept-new"): + hook = GitHook(git_conn_id=CONN_APP_INLINE_KEY) with hook.configure_hook_env(): # Verify get_access_token was called with installation_id kwarg assert mock_integration.get_access_token.call_count == 1 From 1242c681792249659be9385e02fac5893fdf228b Mon Sep 17 00:00:00 2001 From: RaphCodec <46251144+RaphCodec@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:33:52 -0400 Subject: [PATCH 3/3] add auto generated provider docs for optional github dependency --- providers/git/docs/index.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/providers/git/docs/index.rst b/providers/git/docs/index.rst index a1d3937b118cf..1b5d867654aab 100644 --- a/providers/git/docs/index.rst +++ b/providers/git/docs/index.rst @@ -100,6 +100,23 @@ PIP package Version required ``GitPython`` ``>=3.1.44`` ========================================== ================== +Optional dependencies +--------------------- + +These extras install optional third-party libraries that enable additional features of the provider. +Install them when installing from PyPI. For example: + +.. code-block:: bash + + pip install apache-airflow-providers-git[github] + + +========== ====================== +Extra Dependencies +========== ====================== +``github`` ``PyGithub>=2.1.1,<3`` +========== ====================== + Downloading official packages -----------------------------