Skip to content

Commit 8c4614a

Browse files
mattgodboltclaude
andauthored
Use GitHub App for 'This is now live' notifications (#1910)
Co-authored-by: Claude <[email protected]>
1 parent 31b7684 commit 8c4614a

File tree

5 files changed

+313
-17
lines changed

5 files changed

+313
-17
lines changed

bin/lib/cli/blue_green.py

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
elb_client,
1212
get_current_key,
1313
get_releases,
14-
get_ssm_param,
1514
release_for,
1615
)
1716
from lib.aws_utils import get_asg_info, reset_asg_min_size, scale_asg
@@ -20,6 +19,7 @@
2019
from lib.ce_utils import are_you_sure, display_releases
2120
from lib.cli import cli
2221
from lib.env import BLUE_GREEN_ENABLED_ENVIRONMENTS, Config, Environment
22+
from lib.github_app import get_github_app_token
2323
from lib.notify import handle_notify
2424

2525

@@ -310,19 +310,8 @@ def blue_green_deploy(
310310

311311
# Show what would be notified
312312
print("\n🔍 Checking what would be notified...")
313-
try:
314-
gh_token = get_ssm_param("/compiler-explorer/githubAuthToken")
315-
handle_notify(original_commit_hash, target_commit_hash, gh_token, dry_run=True)
316-
except ClientError as e:
317-
print(f"⚠️ Could not retrieve GitHub token ({e})")
318-
print("🔍 Showing commit range that would be checked:")
319-
print(" GitHub API would be queried for commits between:")
320-
print(f" {original_commit_hash} (current deployment)")
321-
print(f" {target_commit_hash} (target deployment)")
322-
print(
323-
f" URL: https://github.com/compiler-explorer/compiler-explorer/compare/{original_commit_hash[:8]}...{target_commit_hash[:8]}"
324-
)
325-
print(" Each commit's linked PRs and issues would be notified with 'This is now live' messages")
313+
gh_token = get_github_app_token()
314+
handle_notify(original_commit_hash, target_commit_hash, gh_token, dry_run=True)
326315

327316
return
328317

@@ -382,7 +371,7 @@ def blue_green_deploy(
382371
if should_notify and cfg.env == Environment.PROD:
383372
if original_commit_hash is not None and target_commit_hash is not None:
384373
try:
385-
gh_token = get_ssm_param("/compiler-explorer/githubAuthToken")
374+
gh_token = get_github_app_token()
386375
print(f"\n{'[DRY RUN] ' if dry_run_notify else ''}Checking for notifications...")
387376
print(
388377
f"Checking commits from {original_commit_hash[:8]}...{original_commit_hash[-8:]} to {target_commit_hash[:8]}...{target_commit_hash[-8:]}"

bin/lib/github_app.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""GitHub App authentication for Compiler Explorer Bot."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import logging
7+
import time
8+
import urllib.error
9+
import urllib.request
10+
11+
import jwt
12+
13+
from lib.amazon import get_ssm_param, ssm_client
14+
15+
LOGGER = logging.getLogger(__name__)
16+
17+
GITHUB_API_URL = "https://api.github.com"
18+
USER_AGENT = "CE GitHub App Auth"
19+
20+
# SSM parameter names
21+
SSM_APP_ID = "/compiler-explorer/github-app-id"
22+
SSM_PRIVATE_KEY = "/compiler-explorer/github-app-private-key"
23+
24+
25+
def generate_jwt(app_id: str, private_key: str) -> str:
26+
"""Generate a JWT for GitHub App authentication.
27+
28+
Args:
29+
app_id: The GitHub App ID
30+
private_key: The private key in PEM format
31+
32+
Returns:
33+
A JWT token valid for 10 minutes
34+
"""
35+
now = int(time.time())
36+
payload = {
37+
"iat": now - 60, # Issued 60 seconds ago to account for clock drift
38+
"exp": now + (10 * 60), # Expires in 10 minutes
39+
"iss": app_id,
40+
}
41+
return jwt.encode(payload, private_key, algorithm="RS256")
42+
43+
44+
def get_installation_id(app_jwt: str, org: str = "compiler-explorer") -> int:
45+
"""Get the installation ID for a GitHub App on an organization.
46+
47+
Args:
48+
app_jwt: JWT token for the GitHub App
49+
org: The organization name to find the installation for
50+
51+
Returns:
52+
The installation ID
53+
54+
Raises:
55+
RuntimeError: If the installation is not found or API request fails
56+
"""
57+
try:
58+
req = urllib.request.Request(
59+
f"{GITHUB_API_URL}/app/installations",
60+
headers={
61+
"User-Agent": USER_AGENT,
62+
"Authorization": f"Bearer {app_jwt}",
63+
"Accept": "application/vnd.github.v3+json",
64+
},
65+
)
66+
result = urllib.request.urlopen(req)
67+
installations = json.loads(result.read())
68+
69+
for installation in installations:
70+
if installation.get("account", {}).get("login") == org:
71+
return installation["id"]
72+
73+
raise RuntimeError(f"GitHub App is not installed on organization '{org}'")
74+
except (OSError, urllib.error.URLError, json.JSONDecodeError) as e:
75+
raise RuntimeError(f"Failed to get GitHub App installations: {e}") from e
76+
77+
78+
def get_installation_token(app_jwt: str, installation_id: int) -> str:
79+
"""Get an installation access token for a GitHub App.
80+
81+
Args:
82+
app_jwt: JWT token for the GitHub App
83+
installation_id: The installation ID
84+
85+
Returns:
86+
An installation access token valid for 1 hour
87+
88+
Raises:
89+
RuntimeError: If the token request fails
90+
"""
91+
try:
92+
req = urllib.request.Request(
93+
f"{GITHUB_API_URL}/app/installations/{installation_id}/access_tokens",
94+
data=b"",
95+
method="POST",
96+
headers={
97+
"User-Agent": USER_AGENT,
98+
"Authorization": f"Bearer {app_jwt}",
99+
"Accept": "application/vnd.github.v3+json",
100+
},
101+
)
102+
result = urllib.request.urlopen(req)
103+
response = json.loads(result.read())
104+
return response["token"]
105+
except (OSError, urllib.error.URLError, json.JSONDecodeError) as e:
106+
raise RuntimeError(f"Failed to get installation access token: {e}") from e
107+
108+
109+
def get_github_app_token() -> str:
110+
"""Get a GitHub installation access token using credentials from SSM.
111+
112+
This function:
113+
1. Retrieves the App ID and private key from AWS SSM
114+
2. Generates a JWT signed with the private key
115+
3. Finds the installation ID for the compiler-explorer org
116+
4. Exchanges the JWT for an installation access token
117+
118+
Returns:
119+
An installation access token for the GitHub App
120+
121+
Raises:
122+
RuntimeError: If credentials are missing or authentication fails
123+
"""
124+
LOGGER.debug("Retrieving GitHub App credentials from SSM")
125+
126+
try:
127+
app_id = get_ssm_param(SSM_APP_ID)
128+
except Exception as e:
129+
raise RuntimeError(f"Failed to get GitHub App ID from SSM ({SSM_APP_ID}): {e}") from e
130+
131+
try:
132+
# Private key is stored as SecureString, needs WithDecryption
133+
private_key = ssm_client.get_parameter(Name=SSM_PRIVATE_KEY, WithDecryption=True)["Parameter"]["Value"]
134+
except Exception as e:
135+
raise RuntimeError(f"Failed to get GitHub App private key from SSM ({SSM_PRIVATE_KEY}): {e}") from e
136+
137+
LOGGER.debug("Generating JWT for GitHub App")
138+
app_jwt = generate_jwt(app_id, private_key)
139+
140+
LOGGER.debug("Getting installation ID for compiler-explorer org")
141+
installation_id = get_installation_id(app_jwt)
142+
143+
LOGGER.debug("Getting installation access token")
144+
return get_installation_token(app_jwt, installation_id)

bin/lib/notify.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def post(entity: str, token: str, query: dict | None = None, dry_run=False) -> d
3030
data=querystring,
3131
headers={
3232
"User-Agent": USER_AGENT,
33-
"Authorization": f"token {token}",
33+
"Authorization": f"Bearer {token}",
3434
"Accept": "application/vnd.github.v3+json",
3535
},
3636
)
@@ -55,7 +55,7 @@ def get(entity: str, token: str, query: dict | None = None) -> dict:
5555
None,
5656
{
5757
"User-Agent": USER_AGENT,
58-
"Authorization": f"token {token}",
58+
"Authorization": f"Bearer {token}",
5959
"Accept": "application/vnd.github.v3+json",
6060
},
6161
)

bin/test/github_app_test.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Tests for the github_app module."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from unittest.mock import MagicMock, patch
7+
8+
import jwt
9+
import pytest
10+
from cryptography.hazmat.backends import default_backend
11+
from cryptography.hazmat.primitives import serialization
12+
from cryptography.hazmat.primitives.asymmetric import rsa
13+
from lib.github_app import (
14+
generate_jwt,
15+
get_github_app_token,
16+
get_installation_id,
17+
get_installation_token,
18+
)
19+
20+
21+
def generate_test_key_pair():
22+
"""Generate a test RSA key pair."""
23+
private_key = rsa.generate_private_key(
24+
public_exponent=65537,
25+
key_size=2048,
26+
backend=default_backend(),
27+
)
28+
29+
pem_private = private_key.private_bytes(
30+
encoding=serialization.Encoding.PEM,
31+
format=serialization.PrivateFormat.TraditionalOpenSSL,
32+
encryption_algorithm=serialization.NoEncryption(),
33+
).decode()
34+
35+
pem_public = (
36+
private_key.public_key()
37+
.public_bytes(
38+
encoding=serialization.Encoding.PEM,
39+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
40+
)
41+
.decode()
42+
)
43+
44+
return pem_private, pem_public
45+
46+
47+
def test_generate_jwt_returns_string():
48+
"""Test that generate_jwt returns a JWT string."""
49+
test_private_key, _ = generate_test_key_pair()
50+
51+
jwt_token = generate_jwt("12345", test_private_key)
52+
53+
assert isinstance(jwt_token, str)
54+
assert len(jwt_token.split(".")) == 3 # JWT has 3 parts separated by dots
55+
56+
57+
def test_generate_jwt_contains_correct_claims():
58+
"""Test that the generated JWT contains the correct claims."""
59+
test_private_key, test_public_key = generate_test_key_pair()
60+
61+
jwt_token = generate_jwt("12345", test_private_key)
62+
63+
decoded = jwt.decode(jwt_token, test_public_key, algorithms=["RS256"])
64+
65+
assert decoded["iss"] == "12345"
66+
assert "iat" in decoded
67+
assert "exp" in decoded
68+
# exp should be about 10 minutes after iat (with 60s clock drift adjustment)
69+
assert abs((decoded["exp"] - decoded["iat"]) - 11 * 60) < 5
70+
71+
72+
def test_get_installation_id_success():
73+
"""Test successful installation ID retrieval."""
74+
mock_response = MagicMock()
75+
mock_response.read.return_value = json.dumps([
76+
{"id": 111, "account": {"login": "other-org"}},
77+
{"id": 222, "account": {"login": "compiler-explorer"}},
78+
]).encode()
79+
80+
with patch("urllib.request.urlopen", return_value=mock_response):
81+
result = get_installation_id("fake_jwt", org="compiler-explorer")
82+
83+
assert result == 222
84+
85+
86+
def test_get_installation_id_not_found():
87+
"""Test error when installation is not found."""
88+
mock_response = MagicMock()
89+
mock_response.read.return_value = json.dumps([
90+
{"id": 111, "account": {"login": "other-org"}},
91+
]).encode()
92+
93+
with patch("urllib.request.urlopen", return_value=mock_response):
94+
with pytest.raises(RuntimeError, match="not installed"):
95+
get_installation_id("fake_jwt", org="compiler-explorer")
96+
97+
98+
def test_get_installation_token_success():
99+
"""Test successful installation token retrieval."""
100+
mock_response = MagicMock()
101+
mock_response.read.return_value = json.dumps({
102+
"token": "ghs_xxxxxxxxxxxxxxxxxxxx",
103+
"expires_at": "2024-01-01T00:00:00Z",
104+
}).encode()
105+
106+
with patch("urllib.request.urlopen", return_value=mock_response):
107+
result = get_installation_token("fake_jwt", 12345)
108+
109+
assert result == "ghs_xxxxxxxxxxxxxxxxxxxx"
110+
111+
112+
def test_get_github_app_token_success():
113+
"""Test successful end-to-end token retrieval."""
114+
test_private_key, _ = generate_test_key_pair()
115+
116+
mock_ssm_client = MagicMock()
117+
mock_ssm_client.get_parameter.return_value = {"Parameter": {"Value": test_private_key}}
118+
119+
mock_installations_response = MagicMock()
120+
mock_installations_response.read.return_value = json.dumps([
121+
{"id": 67890, "account": {"login": "compiler-explorer"}},
122+
]).encode()
123+
124+
mock_token_response = MagicMock()
125+
mock_token_response.read.return_value = json.dumps({
126+
"token": "ghs_test_token_12345",
127+
}).encode()
128+
129+
with (
130+
patch("lib.github_app.get_ssm_param", return_value="12345"),
131+
patch("lib.github_app.ssm_client", mock_ssm_client),
132+
patch("urllib.request.urlopen", side_effect=[mock_installations_response, mock_token_response]),
133+
):
134+
result = get_github_app_token()
135+
136+
assert result == "ghs_test_token_12345"
137+
138+
139+
def test_get_github_app_token_missing_app_id():
140+
"""Test error when App ID is missing from SSM."""
141+
142+
def mock_get_ssm_param(param):
143+
if "app-id" in param:
144+
raise Exception("Parameter not found")
145+
return "some_value"
146+
147+
with patch("lib.github_app.get_ssm_param", side_effect=mock_get_ssm_param):
148+
with pytest.raises(RuntimeError, match="App ID"):
149+
get_github_app_token()
150+
151+
152+
def test_get_github_app_token_missing_private_key():
153+
"""Test error when private key is missing from SSM."""
154+
mock_ssm_client = MagicMock()
155+
mock_ssm_client.get_parameter.side_effect = Exception("Parameter not found")
156+
157+
with (
158+
patch("lib.github_app.get_ssm_param", return_value="12345"),
159+
patch("lib.github_app.ssm_client", mock_ssm_client),
160+
):
161+
with pytest.raises(RuntimeError, match="private key"):
162+
get_github_app_token()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dependencies = [
2222
"requests-cache>=1.2.1",
2323
"matplotlib>=3.10.5",
2424
"pillow>=11.3.0",
25+
"PyJWT[crypto]>=2.8.0",
2526
]
2627

2728
[dependency-groups]

0 commit comments

Comments
 (0)