Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5a2943f
add GitHub App Authentication as an option for DagBundles
RaphCodec Mar 29, 2026
b33349b
chore: change PyGithub package name to canonical package name casing
RaphCodec Apr 19, 2026
e4233d1
Update providers/git/src/airflow/providers/git/hooks/git.py
RaphCodec Apr 19, 2026
4575d77
update docstring
RaphCodec Apr 19, 2026
6007824
add error handling to validate the data types for github app id and i…
RaphCodec Apr 19, 2026
38aadb9
chore: add error handling in case PyGithub library is missing when im…
RaphCodec Apr 19, 2026
381a73f
test: update git hook tests to include tests for github app authentic…
RaphCodec Apr 19, 2026
7e286a1
chore: clarifying Github Client comment
RaphCodec Apr 19, 2026
e8239e5
chore: ran prek hooks to format and lint
RaphCodec Apr 19, 2026
be8a5fd
Fix: assert hook.private_key outside pytest.raises in test_app_auth_w…
RaphCodec Apr 19, 2026
0261ae5
fix: correct security issue so that the github app private key is not…
RaphCodec Apr 19, 2026
adbd368
chore: formatted, linted and updated airflow exception with more spec…
RaphCodec Apr 19, 2026
c9d1504
feat: update github app auth to support client id and app id
RaphCodec May 2, 2026
558e07a
fix: reduced number of uv.lock file changes to refelct only the chang…
RaphCodec May 16, 2026
edc256f
fix: clarify github_client_id type and let PyGithub validate data types
RaphCodec May 16, 2026
96f405b
fix: switch github app auth to use github.Auth.AppAuth to remove depr…
RaphCodec May 17, 2026
828d7a6
feat: add github app token refresh
RaphCodec May 17, 2026
665ee1b
fix: remove persisten access token from url and defered github app to…
RaphCodec Jun 5, 2026
f43cbbe
made pygithub lib bounded to versions >=2.1.1,<3
RaphCodec Jun 5, 2026
08dd276
fix: standardize exceptions types
RaphCodec Jun 6, 2026
1430b45
feat: updaed github app auth tests to include refresh test
RaphCodec Jun 6, 2026
48a2f7b
chore: converted new airflow exceptions to ValueError and reverted kn…
RaphCodec Jun 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions providers/git/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
120 changes: 119 additions & 1 deletion providers/git/src/airflow/providers/git/hooks/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import shlex
import stat
import tempfile
from datetime import datetime, timedelta, timezone
from typing import Any
from urllib.parse import quote as urlquote

Expand All @@ -49,6 +50,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 @@ -76,6 +81,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 @@ -104,10 +111,36 @@ 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:
raise AirflowException("Both 'key_file' and 'private_key' cannot be provided at the same time")
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()
Comment thread
RaphCodec marked this conversation as resolved.

_VALID_STRICT_HOST_KEY_CHECKING = frozenset({"yes", "no", "accept-new", "off", "ask"})
Expand Down Expand Up @@ -142,6 +175,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
Expand Down Expand Up @@ -199,7 +308,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()
Expand Down
Loading