Files
BrowserOS/packages/browseros/build/modules/release/github.py
2026-01-06 17:38:21 -08:00

240 lines
7.3 KiB
Python
Generated

#!/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!")