Skip to content
Open
36 changes: 36 additions & 0 deletions providers/git/docs/connections/git.rst
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,39 @@ Extra (optional)
"strict_host_key_checking": "yes",
"known_hosts_file": "/path/to/known_hosts"
}

**GitHub App authentication:**

In order to use GitHub App authentication the ``github`` extra needs to be installed:

.. code-block:: bash

pip install 'apache-airflow-providers-git[github]'

* ``github_app_id``: The App ID of your GitHub App. Note that the GitHub App Client ID can also be used.
* ``github_installation_id``: The installation ID of your GitHub app.
* ``key_file``: Path to a PEM-encoded private key file for your GitHub App.
* ``private_key``: An inline PEM-encoded private key string. When provided, the hook writes it
to a temporary file and uses it for the GitHub App connection.
Mutually exclusive with ``key_file``.


Example with key file:

.. code-block:: json

{
"github_app_id": "1234567",
"github_installation_id": "67890",
"key_file": "/path/to/private-key.pem"
}

Example with inline private key:

.. code-block:: json

{
"github_app_id": "1234567",
"github_installation_id": "67890",
"private_key": "<content of your PEM-encoded private key>"
}
17 changes: 17 additions & 0 deletions providers/git/docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
========== ===================

Downloading official packages
-----------------------------

Expand Down
8 changes: 8 additions & 0 deletions providers/git/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,21 @@ 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",
]

[dependency-groups]
dev = [
"apache-airflow",
"apache-airflow-task-sdk",
"apache-airflow-devel-common",
"apache-airflow-providers-common-compat",
# Additional devel dependencies (do not remove this line and add extra development dependencies)
"PyGithub>=2.1.1"
]

# To build docs:
Expand Down
120 changes: 118 additions & 2 deletions providers/git/src/airflow/providers/git/hooks/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@
import stat
import tempfile
import warnings
from collections.abc import Generator
from datetime import datetime, timedelta, timezone
from typing import Any
from urllib.parse import quote as urlquote

from airflow.exceptions import AirflowProviderDeprecationWarning
from airflow.providers.common.compat.sdk import BaseHook
from airflow.providers.common.compat.sdk import AirflowOptionalProviderFeatureException, BaseHook

log = logging.getLogger(__name__)

Expand All @@ -53,6 +55,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"
Expand Down Expand Up @@ -80,6 +86,8 @@ def get_ui_field_behaviour(cls) -> dict[str, Any]:
"ssh_config_file": "",
"host_proxy_cmd": "",
"ssh_port": "",
"github_app_id": "",
"github_installation_id": "",
}
)
},
Expand Down Expand Up @@ -110,6 +118,11 @@ 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_token_exp: datetime | None = None

self.env: dict[str, str] = {}

if self.key_file and self.private_key:
Expand All @@ -127,6 +140,21 @@ def __init__(
AirflowProviderDeprecationWarning,
stacklevel=2,
)
github_app_fields = (self.github_app_id, self.github_installation_id)
if any(github_app_fields) and not all(github_app_fields):
raise ValueError(
"Both 'github_app_id' and 'github_installation_id' must be provided to use GitHub App Authentication"
)
if all(github_app_fields):
if self.auth_token:
raise ValueError("Password field must be empty to use GitHub App Auth")
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}"
)
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()
self._process_git_auth_url()
Comment thread
RaphCodec marked this conversation as resolved.

_VALID_STRICT_HOST_KEY_CHECKING = frozenset({"yes", "no", "accept-new", "off", "ask"})
Expand Down Expand Up @@ -183,7 +211,89 @@ def _build_ssh_command(self, key_path: str | None = None) -> str:

return " ".join(parts)

def _process_git_auth_url(self):
def _get_github_app_token(self):
Comment thread
RaphCodec marked this conversation as resolved.
try:
from github import Auth, GithubIntegration
except ImportError as exc:
raise AirflowOptionalProviderFeatureException(
"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.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:
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) -> Generator[None]:
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_lc_all = os.environ.get("LC_ALL")
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["LC_ALL"] = "C"
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_lc_all is None:
self.env.pop("LC_ALL", None)
os.environ.pop("LC_ALL", None)
else:
self.env["LC_ALL"] = old_lc_all
os.environ["LC_ALL"] = old_lc_all

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) -> None:
if not isinstance(self.repo_url, str):
return
if self.auth_token and self.repo_url.startswith("https://"):
Expand Down Expand Up @@ -240,6 +350,12 @@ def _passphrase_askpass_env(self):

@contextlib.contextmanager
def configure_hook_env(self):
if self.github_app_id is not None and self.github_installation_id is not None:
self._ensure_github_app_token()
with self._github_app_askpass_env():
yield
return

if self.private_key:
with tempfile.NamedTemporaryFile(mode="w", delete=True) as tmp_keyfile:
tmp_keyfile.write(self.private_key)
Expand Down
Loading
Loading