Skip to content

Commit 10ed156

Browse files
committed
GitHub-App auth demo
1 parent acd2f20 commit 10ed156

File tree

3 files changed

+276
-1
lines changed

3 files changed

+276
-1
lines changed

src/redis_release/cli.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,5 +321,120 @@ def slack_bot(
321321
)
322322

323323

324+
@app.command()
325+
def test_github_app(
326+
github_app_id: str = typer.Option(..., "--github-app-id", help="GitHub App ID"),
327+
github_private_key_file: str = typer.Option(
328+
..., "--github-private-key-file", help="Path to GitHub App private key file"
329+
),
330+
repo: str = typer.Option(
331+
"redis/docker-library-redis",
332+
"--repo",
333+
help="Repository to test (default: redis/docker-library-redis)",
334+
),
335+
workflow_file: str = typer.Option(
336+
"release_build_and_test.yml",
337+
"--workflow-file",
338+
help="Workflow file to dispatch (default: release_build_and_test.yml)",
339+
),
340+
workflow_ref: str = typer.Option(
341+
"main", "--workflow-ref", help="Git ref to run workflow on (default: main)"
342+
),
343+
workflow_inputs: Optional[str] = typer.Option(
344+
None,
345+
"--workflow-inputs",
346+
help='Workflow inputs as JSON string (e.g., \'{"key": "value"}\')',
347+
),
348+
) -> None:
349+
"""[TEST] Test GitHub App authentication and workflow dispatch.
350+
351+
This command tests GitHub App authentication by:
352+
1. Loading the private key from file
353+
2. Generating a JWT token
354+
3. Getting an installation token for the specified repository
355+
4. Dispatching a workflow using the installation token
356+
357+
Example:
358+
redis-release test-github-app \\
359+
--github-app-id 123456 \\
360+
--github-private-key-file /path/to/private-key.pem \\
361+
--repo redis/docker-library-redis \\
362+
--workflow-file release_build_and_test.yml \\
363+
--workflow-ref main \\
364+
--workflow-inputs '{"release_tag": "8.4-m01-int1"}'
365+
"""
366+
setup_logging()
367+
368+
try:
369+
import json
370+
371+
from .github_app_auth import GitHubAppAuth, load_private_key_from_file
372+
from .github_client_async import GitHubClientAsync
373+
374+
# Load private key
375+
logger.info(f"Loading private key from {github_private_key_file}")
376+
private_key = load_private_key_from_file(github_private_key_file)
377+
logger.info("[green]Private key loaded successfully[/green]")
378+
379+
# Create GitHub App auth helper
380+
app_auth = GitHubAppAuth(app_id=github_app_id, private_key=private_key)
381+
382+
# Get installation token
383+
logger.info(f"Getting installation token for repo: {repo}")
384+
385+
async def get_token_and_dispatch() -> None:
386+
token = await app_auth.get_token_for_repo(repo)
387+
if not token:
388+
logger.error("[red]Failed to get installation token[/red]")
389+
raise typer.Exit(1)
390+
391+
logger.info("[green]Successfully obtained installation token[/green]")
392+
logger.info(f"Token (first 20 chars): {token[:20]}...")
393+
394+
# Parse workflow inputs
395+
inputs = {}
396+
if workflow_inputs:
397+
try:
398+
inputs = json.loads(workflow_inputs)
399+
logger.info(f"Workflow inputs: {inputs}")
400+
except json.JSONDecodeError as e:
401+
logger.error(f"[red]Invalid JSON in workflow inputs:[/red] {e}")
402+
raise typer.Exit(1)
403+
404+
# Create GitHub client with the installation token
405+
github_client = GitHubClientAsync(token=token)
406+
407+
# Dispatch workflow
408+
logger.info(
409+
f"Dispatching workflow {workflow_file} on {repo} at ref {workflow_ref}"
410+
)
411+
try:
412+
await github_client.trigger_workflow(
413+
repo=repo,
414+
workflow_file=workflow_file,
415+
inputs=inputs,
416+
ref=workflow_ref,
417+
)
418+
logger.info("[green]Workflow dispatched successfully![/green]")
419+
logger.info(
420+
f"Check workflow runs at: https://github.com/{repo}/actions"
421+
)
422+
except Exception as e:
423+
logger.error(f"[red]Failed to dispatch workflow:[/red] {e}")
424+
raise typer.Exit(1)
425+
426+
# Run async function
427+
asyncio.run(get_token_and_dispatch())
428+
429+
except FileNotFoundError:
430+
logger.error(
431+
f"[red]Private key file not found:[/red] {github_private_key_file}"
432+
)
433+
raise typer.Exit(1)
434+
except Exception as e:
435+
logger.error(f"[red]Unexpected error:[/red] {e}", exc_info=True)
436+
raise typer.Exit(1)
437+
438+
324439
if __name__ == "__main__":
325440
app()
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""GitHub App authentication utilities."""
2+
3+
import logging
4+
import time
5+
from typing import Optional
6+
7+
import aiohttp
8+
import jwt
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class GitHubAppAuth:
14+
"""GitHub App authentication helper."""
15+
16+
def __init__(self, app_id: str, private_key: str):
17+
"""Initialize GitHub App authentication.
18+
19+
Args:
20+
app_id: GitHub App ID
21+
private_key: GitHub App private key (PEM format)
22+
"""
23+
self.app_id = app_id
24+
self.private_key = private_key
25+
26+
def generate_jwt(self, expiration_seconds: int = 600) -> str:
27+
"""Generate a JWT for GitHub App authentication.
28+
29+
Args:
30+
expiration_seconds: JWT expiration time in seconds (max 600)
31+
32+
Returns:
33+
JWT token string
34+
"""
35+
now = int(time.time())
36+
payload = {
37+
"iat": now
38+
- 60, # Issued at time (60 seconds in the past to account for clock drift)
39+
"exp": now + expiration_seconds, # Expiration time
40+
"iss": self.app_id, # Issuer (GitHub App ID)
41+
}
42+
43+
# Generate JWT using RS256 algorithm
44+
token = jwt.encode(payload, self.private_key, algorithm="RS256")
45+
logger.debug(f"Generated JWT for GitHub App {self.app_id}")
46+
return str(token)
47+
48+
async def get_installation_id(self, repo: str, jwt_token: str) -> Optional[int]:
49+
"""Get the installation ID for a repository.
50+
51+
Args:
52+
repo: Repository name in format "owner/repo"
53+
jwt_token: JWT token for authentication
54+
55+
Returns:
56+
Installation ID or None if not found
57+
"""
58+
url = f"https://api.github.com/repos/{repo}/installation"
59+
headers = {
60+
"Authorization": f"Bearer {jwt_token}",
61+
"Accept": "application/vnd.github+json",
62+
"X-GitHub-Api-Version": "2022-11-28",
63+
}
64+
65+
async with aiohttp.ClientSession() as session:
66+
async with session.get(url, headers=headers) as response:
67+
if response.status == 200:
68+
data = await response.json()
69+
installation_id: Optional[int] = data.get("id")
70+
logger.info(
71+
f"Found installation ID {installation_id} for repo {repo}"
72+
)
73+
return installation_id
74+
else:
75+
error_text = await response.text()
76+
logger.error(
77+
f"Failed to get installation ID for {repo}: HTTP {response.status}"
78+
)
79+
logger.error(f"Response: {error_text}")
80+
return None
81+
82+
async def get_installation_token(
83+
self, installation_id: int, jwt_token: str
84+
) -> Optional[str]:
85+
"""Get an installation access token.
86+
87+
Args:
88+
installation_id: GitHub App installation ID
89+
jwt_token: JWT token for authentication
90+
91+
Returns:
92+
Installation access token or None if failed
93+
"""
94+
url = (
95+
f"https://api.github.com/app/installations/{installation_id}/access_tokens"
96+
)
97+
headers = {
98+
"Authorization": f"Bearer {jwt_token}",
99+
"Accept": "application/vnd.github+json",
100+
"X-GitHub-Api-Version": "2022-11-28",
101+
}
102+
103+
async with aiohttp.ClientSession() as session:
104+
async with session.post(url, headers=headers) as response:
105+
if response.status == 201:
106+
data = await response.json()
107+
token: Optional[str] = data.get("token")
108+
expires_at = data.get("expires_at")
109+
logger.info(
110+
f"Generated installation token (expires at {expires_at})"
111+
)
112+
return token
113+
else:
114+
error_text = await response.text()
115+
logger.error(
116+
f"Failed to get installation token: HTTP {response.status}"
117+
)
118+
logger.error(f"Response: {error_text}")
119+
return None
120+
121+
async def get_token_for_repo(self, repo: str) -> Optional[str]:
122+
"""Get an installation access token for a specific repository.
123+
124+
This is a convenience method that combines JWT generation, installation ID lookup,
125+
and installation token generation.
126+
127+
Args:
128+
repo: Repository name in format "owner/repo"
129+
130+
Returns:
131+
Installation access token or None if failed
132+
"""
133+
# Generate JWT
134+
jwt_token = self.generate_jwt()
135+
136+
# Get installation ID
137+
installation_id = await self.get_installation_id(repo, jwt_token)
138+
if not installation_id:
139+
return None
140+
141+
# Get installation token
142+
return await self.get_installation_token(installation_id, jwt_token)
143+
144+
145+
def load_private_key_from_file(file_path: str) -> str:
146+
"""Load GitHub App private key from a file.
147+
148+
Args:
149+
file_path: Path to the private key file
150+
151+
Returns:
152+
Private key content as string
153+
154+
Raises:
155+
FileNotFoundError: If the file doesn't exist
156+
IOError: If the file can't be read
157+
"""
158+
with open(file_path, "r") as f:
159+
return f.read()

src/redis_release/github_client_async.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,8 @@ async def trigger_workflow(
242242
logger.debug(f"[blue]Triggering workflow[/blue] {workflow_file} in {repo}")
243243
logger.debug(f"Inputs: {inputs}")
244244
logger.debug(f"Ref: {ref}")
245-
logger.debug(f"Workflow UUID: [cyan]{inputs['workflow_uuid']}[/cyan]")
245+
if "workflow_uuid" in inputs:
246+
logger.debug(f"Workflow UUID: [cyan]{inputs['workflow_uuid']}[/cyan]")
246247

247248
url = f"https://api.github.com/repos/{repo}/actions/workflows/{workflow_file}/dispatches"
248249
headers = {

0 commit comments

Comments
 (0)