#!/usr/bin/env python3 """GitHub module - Create GitHub releases from R2 artifacts""" import subprocess import tempfile from pathlib import Path from typing import Dict, List, Optional, Tuple from ...common.context import Context from ...common.module import CommandModule, ValidationError from ...common.utils import log_info, log_error, log_success, log_warning from ..storage import BOTO3_AVAILABLE from .common import ( PLATFORMS, PLATFORM_DISPLAY_NAMES, fetch_all_release_metadata, generate_appcast_item, generate_release_notes, get_repo_from_git, check_gh_cli, ) def create_github_release( version: str, repo: str, title: str, notes: str, draft: bool = True, ) -> Tuple[bool, str]: """Create GitHub release via gh CLI""" cmd = [ "gh", "release", "create", f"v{version}", "--repo", repo, "--title", title, "--notes", notes, ] if draft: cmd.append("--draft") try: result = subprocess.run(cmd, capture_output=True, text=True, check=True) return True, result.stdout.strip() except subprocess.CalledProcessError as e: if "already exists" in e.stderr: return False, f"Release v{version} already exists" return False, e.stderr def download_file(url: str, dest: Path) -> bool: """Download file from URL using curl""" try: subprocess.run( ["curl", "-L", "-o", str(dest), url], check=True, capture_output=True, ) return True except Exception: return False def upload_to_github_release(version: str, repo: str, file_path: Path) -> bool: """Upload file to existing GitHub release""" try: subprocess.run( ["gh", "release", "upload", f"v{version}", str(file_path), "--repo", repo], check=True, capture_output=True, ) return True except Exception: return False def normalize_version(version: str) -> str: """Normalize version to MAJOR.MINOR.BUILD (strip patch if present)""" parts = version.split(".") if len(parts) >= 3: return ".".join(parts[:3]) return version def download_and_upload_artifacts( version: str, repo: str, metadata: Dict[str, Dict], platforms: Optional[List[str]] = None, ) -> List[Tuple[str, bool]]: """Download artifacts from R2 and upload to GitHub release""" if platforms is None: platforms = PLATFORMS results = [] with tempfile.TemporaryDirectory() as tmpdir: tmppath = Path(tmpdir) for platform in platforms: if platform not in metadata: continue for key, artifact in metadata[platform].get("artifacts", {}).items(): url = artifact["url"] filename = artifact["filename"] local_path = tmppath / filename log_info(f" Downloading {filename}...") if not download_file(url, local_path): log_error(f" Failed to download {filename}") results.append((filename, False)) continue log_info(f" Uploading {filename}...") if upload_to_github_release(version, repo, local_path): log_success(f" Uploaded {filename}") results.append((filename, True)) else: log_error(f" Failed to upload {filename}") results.append((filename, False)) return results class GithubModule(CommandModule): """Create GitHub release from R2 artifacts""" produces = [] requires = [] description = "Create GitHub release from R2 artifacts" def __init__( self, draft: bool = True, skip_upload: bool = False, title: Optional[str] = None, ): self.draft = draft self.skip_upload = skip_upload self.title = title def validate(self, ctx: Context) -> None: if not BOTO3_AVAILABLE: raise ValidationError( "boto3 library not installed - run: pip install boto3" ) if not ctx.env.has_r2_config(): raise ValidationError("R2 configuration not set") if not ctx.release_version: raise ValidationError("--version is required") if not check_gh_cli(): raise ValidationError( "gh CLI not found. Install from: https://cli.github.com" ) # Determine repo if not ctx.github_repo: repo = get_repo_from_git() if not repo: raise ValidationError( "Could not detect repo from git remote. Use --repo flag." ) ctx.github_repo = repo def execute(self, ctx: Context) -> None: version = ctx.release_version tag_version = normalize_version(version) repo = ctx.github_repo metadata = fetch_all_release_metadata(version, ctx.env) if not metadata: log_error(f"No release metadata found for version {version}") return log_info(f"\n{'='*60}") log_info(f"Creating GitHub Release: v{tag_version}") log_info(f"{'='*60}") for platform, release in metadata.items(): artifacts = release.get("artifacts", {}) log_info(f" {PLATFORM_DISPLAY_NAMES[platform]}: {len(artifacts)} artifact(s)") log_info(f" Repo: {repo}") log_info(f" Draft: {self.draft}") # Create release release_title = self.title or f"v{tag_version}" notes = generate_release_notes(tag_version, metadata) log_info("\nCreating GitHub release...") success, result = create_github_release(tag_version, repo, release_title, notes, self.draft) if success: log_success(f"Release created: {result}") else: if "already exists" in result: log_warning(result) else: log_error(f"Failed to create release: {result}") return # Upload artifacts if not self.skip_upload: log_info("\nUploading artifacts to GitHub release...") results = download_and_upload_artifacts(tag_version, repo, metadata) failed = [f for f, ok in results if not ok] if failed: log_warning(f"Failed to upload: {', '.join(failed)}") # Print appcast snippet if "macos" in metadata: log_info("\n" + "=" * 60) log_info("APPCAST SNIPPET") log_info("=" * 60) release = metadata["macos"] sparkle_version = release.get("sparkle_version", "") build_date = release.get("build_date", "") arch_to_file = {"arm64": "appcast.xml", "x64": "appcast-x86_64.xml", "universal": "appcast.xml"} for arch in ["arm64", "x64", "universal"]: if arch in release.get("artifacts", {}): artifact = release["artifacts"][arch] log_info(f"\n{arch_to_file[arch]} ({arch}):") print(generate_appcast_item(artifact, tag_version, sparkle_version, build_date)) log_info(f"\n{'='*60}") log_success(f"Release v{tag_version} complete!")