diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..a26f881 --- /dev/null +++ b/.claude/CLAUDE.md @@ -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 diff --git a/.claude/get_github_app_token.py b/.claude/get_github_app_token.py new file mode 100755 index 0000000..62dcf13 --- /dev/null +++ b/.claude/get_github_app_token.py @@ -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) diff --git a/.claude/run-e2e-tests.sh b/.claude/run-e2e-tests.sh new file mode 100755 index 0000000..b81bf48 --- /dev/null +++ b/.claude/run-e2e-tests.sh @@ -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 "$@" diff --git a/get_github_app_token.py b/get_github_app_token.py deleted file mode 100755 index adbce6a..0000000 --- a/get_github_app_token.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env -S uv run -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "PyJWT", -# "requests", -# "cryptography", -# ] -# /// -""" -GitHub App Token Generator - -Generates an installation access token from GitHub App credentials in environment. - -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 os -import sys -import time - -import jwt -import requests - - -def get_installation_token(): - """Generate an 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() - return token_info['token'] - - -if __name__ == "__main__": - try: - token = get_installation_token() - print(token) - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1)