Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Claude Scripts

`run-e2e-tests.sh` - Idempotent script that installs gh, gets a cached GitHub App token, and runs e2e tests
167 changes: 167 additions & 0 deletions .claude/get_github_app_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "PyJWT",
# "requests",
# "cryptography",
# ]
# ///
"""
GitHub App Token Generator with caching

Generates an installation access token from GitHub App credentials in environment.
Caches tokens and reuses them until they expire (with 5-minute buffer).

Environment variables required:
- GH_APP_ID: GitHub App ID
- GH_APP_PRIVATE_KEY_PEM_B64: Base64-encoded private key

Usage:
# Run with uv (automatically installs dependencies)
uv run get_github_app_token.py

# Save to file
uv run get_github_app_token.py > token.txt

# Use in shell
export GITHUB_TOKEN=$(uv run get_github_app_token.py)
"""

import base64
import json
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

import jwt
import requests

# Cache file location
CACHE_FILE = Path("/tmp/gh_app_token_cache.json")
# Buffer time before expiration (5 minutes)
EXPIRATION_BUFFER_SECONDS = 300


def get_cached_token():
"""Get cached token if it exists and is still valid."""
if not CACHE_FILE.exists():
return None

try:
with open(CACHE_FILE, 'r') as f:
cache_data = json.load(f)

token = cache_data.get('token')
expires_at_str = cache_data.get('expires_at')

if not token or not expires_at_str:
return None

# Parse expiration time (GitHub returns ISO 8601 format)
expires_at = datetime.fromisoformat(expires_at_str.replace('Z', '+00:00'))
now = datetime.now(timezone.utc)

# Check if token is still valid (with buffer)
time_until_expiry = (expires_at - now).total_seconds()

if time_until_expiry > EXPIRATION_BUFFER_SECONDS:
print(f"Using cached token (expires in {int(time_until_expiry/60)} minutes)", file=sys.stderr)
return token
else:
print("Cached token expired or expiring soon, generating new token", file=sys.stderr)
return None

except (json.JSONDecodeError, ValueError, KeyError) as e:
print(f"Error reading cache: {e}, generating new token", file=sys.stderr)
return None


def save_token_to_cache(token, expires_at):
"""Save token and expiration to cache file."""
cache_data = {
'token': token,
'expires_at': expires_at,
'cached_at': datetime.now(timezone.utc).isoformat()
}

try:
with open(CACHE_FILE, 'w') as f:
json.dump(cache_data, f)
print("Token cached successfully", file=sys.stderr)
except Exception as e:
print(f"Warning: Failed to cache token: {e}", file=sys.stderr)


def generate_installation_token():
"""Generate a new installation access token for the GitHub App."""
# Get credentials from environment
app_id = os.getenv("GH_APP_ID")
private_key_b64 = os.getenv("GH_APP_PRIVATE_KEY_PEM_B64")

if not all([app_id, private_key_b64]):
print("Error: Missing GH_APP_ID or GH_APP_PRIVATE_KEY_PEM_B64", file=sys.stderr)
sys.exit(1)

# Decode private key
private_key = base64.b64decode(private_key_b64).decode('utf-8')

# Generate JWT
now = int(time.time())
payload = {
"iat": now - 60,
"exp": now + (10 * 60),
"iss": app_id,
}
jwt_token = jwt.encode(payload, private_key, algorithm="RS256")

# Get installations
headers = {
"Authorization": f"Bearer {jwt_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}

response = requests.get("https://api.github.com/app/installations", headers=headers)
response.raise_for_status()
installations = response.json()

if not installations:
print("Error: No installations found for this GitHub App", file=sys.stderr)
sys.exit(1)

# Create installation token
installation_id = installations[0]['id']
url = f"https://api.github.com/app/installations/{installation_id}/access_tokens"
response = requests.post(url, headers=headers)
response.raise_for_status()

token_info = response.json()

# Save to cache
save_token_to_cache(token_info['token'], token_info['expires_at'])

return token_info['token']


def get_token():
"""Get a valid token, either from cache or by generating a new one."""
# Try to get cached token first
cached_token = get_cached_token()
if cached_token:
return cached_token

# Generate new token if cache miss or expired
print("Generating new GitHub App token", file=sys.stderr)
return generate_installation_token()


if __name__ == "__main__":
try:
token = get_token()
print(token)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
154 changes: 154 additions & 0 deletions .claude/run-e2e-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/bin/bash
# Idempotent script to install gh CLI, acquire token, and run e2e tests
set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR/.."
TOKEN_FILE="/tmp/gh_token.txt"

log_info() {
echo "[INFO] $1"
}

log_warn() {
echo "[WARN] $1"
}

log_error() {
echo "[ERROR] $1" >&2
}

# 1. Install gh CLI if not present
install_gh_cli() {
if command -v gh &> /dev/null; then
local version=$(gh --version | head -n1)
log_info "gh CLI already installed: $version"
return 0
fi

log_info "Installing gh CLI..."

local arch=$(uname -m)
if [[ "$arch" != "x86_64" ]]; then
log_error "Unsupported architecture: $arch (only x86_64 is supported)"
return 1
fi

# Get latest release URL
local download_url=$(curl -s https://api.github.com/repos/cli/cli/releases/latest | \
grep "browser_download_url.*linux_amd64.tar.gz\"" | \
cut -d '"' -f 4)

if [[ -z "$download_url" ]]; then
log_error "Failed to get gh CLI download URL"
return 1
fi

log_info "Downloading from: $download_url"

# Download and install
cd /tmp
curl -L -o gh_linux_amd64.tar.gz "$download_url"
tar -xzf gh_linux_amd64.tar.gz
sudo cp gh_*/bin/gh /usr/local/bin/
sudo chmod +x /usr/local/bin/gh
rm -rf gh_*

local version=$(gh --version | head -n1)
log_info "Successfully installed gh CLI: $version"
}

# 2. Acquire GitHub App token
acquire_token() {
log_info "Acquiring GitHub App token..."

# Check if required environment variables are set
if [[ -z "$GH_APP_ID" ]] || [[ -z "$GH_APP_PRIVATE_KEY_PEM_B64" ]]; then
log_error "Missing required environment variables: GH_APP_ID and/or GH_APP_PRIVATE_KEY_PEM_B64"
return 1
fi

# Generate token using the Python script
if ! uv run "$SCRIPT_DIR/get_github_app_token.py" > "$TOKEN_FILE" 2>/dev/null; then
log_error "Failed to generate GitHub App token"
return 1
fi

local token_preview=$(cat "$TOKEN_FILE" | head -c 10)
log_info "Successfully acquired token: ${token_preview}..."
}

# 3. Setup git config and gh auth
setup_git_and_gh() {
log_info "Setting up git configuration..."

# Save current git config state
ORIGINAL_GPGSIGN=$(git config --global --get commit.gpgsign || echo "not-set")

# Disable commit signing for tests
git config --global commit.gpgsign false

# Setup gh authentication (gh uses GH_TOKEN)
export GH_TOKEN="$(cat "$TOKEN_FILE")"

log_info "Configuring gh auth..."
gh auth setup-git

log_info "Git and gh authentication configured"
}

# 4. Restore git config
restore_git_config() {
log_info "Restoring git configuration..."

if [[ "$ORIGINAL_GPGSIGN" == "not-set" ]]; then
git config --global --unset commit.gpgsign || true
else
git config --global commit.gpgsign "$ORIGINAL_GPGSIGN"
fi

log_info "Git configuration restored"
}

# 5. Run e2e tests
run_e2e_tests() {
log_info "Running e2e tests..."

# GH_TOKEN is already exported from setup_git_and_gh, no need to re-export

# Run the tests (timeout should be handled externally)
if bash "$PROJECT_ROOT/tests/test_e2e.sh"; then
log_info "✅ E2E tests completed successfully!"
return 0
else
local exit_code=$?
log_error "❌ E2E tests failed with exit code: $exit_code"
return $exit_code
fi
}

# Main execution
main() {
log_info "Starting E2E test setup and execution..."
log_info "Project root: $PROJECT_ROOT"

# Trap to ensure cleanup happens
trap restore_git_config EXIT

# Execute steps
install_gh_cli || exit 1
acquire_token || exit 1
setup_git_and_gh || exit 1

# Run tests (allow failure to be handled by exit code)
if run_e2e_tests; then
log_info "🎉 All steps completed successfully!"
exit 0
else
log_error "Tests failed, but cleanup will still occur"
exit 1
fi
}

# Run main function
main "$@"
Loading