| """ |
| FastAPI β DrivePurge Release Automation |
| ======================================== |
| HF Spaces ready (port 7860). |
| |
| Endpoints |
| --------- |
| GET /health |
| POST /update-file |
| GET /releases |
| GET /releases/{tag} |
| POST /process-release/{tag} |
| POST /trigger β full pipeline |
| """ |
|
|
| from __future__ import annotations |
|
|
| import base64 |
| import json |
| import os |
| import re |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Optional |
|
|
| import httpx |
| from fastapi import FastAPI, HTTPException |
| from fastapi.middleware.cors import CORSMiddleware |
| from pydantic import BaseModel, Field |
|
|
| |
| GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "") |
| REPO_OWNER = os.getenv("REPO_OWNER", "Drive-Purge") |
| REPO_NAME = os.getenv("REPO_NAME", "DrivePurge_") |
| OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "/tmp/releases")) |
|
|
| BASE_URL = "https://api.github.com" |
|
|
| def _gh_headers() -> dict: |
| h = { |
| "Accept": "application/vnd.github+json", |
| "X-GitHub-Api-Version": "2022-11-28", |
| } |
| if GITHUB_TOKEN: |
| h["Authorization"] = f"Bearer {GITHUB_TOKEN}" |
| return h |
|
|
| OUTPUT_DIR.mkdir(parents=True, exist_ok=True) |
|
|
| |
| |
| |
| |
| |
| _OS_RULES: list[tuple[str, str, str]] = [ |
| |
| (r"windows.*amd64|win.*x64|win.*amd64", "windows", "x86_64"), |
| (r"windows.*arm64|win.*arm64", "windows", "arm64"), |
| (r"windows.*arm\b|win.*arm\b", "windows", "arm"), |
| (r"\.exe$|windows|win\b", "windows", "x86_64"), |
| |
| (r"macos.*arm64|mac.*arm64|darwin.*arm64", "macos", "arm64"), |
| (r"macos.*amd64|mac.*amd64|darwin.*amd64", "macos", "x86_64"), |
| (r"macos.*arm\b|mac.*arm\b", "macos", "arm64"), |
| (r"macos|darwin|osx", "macos", "universal"), |
| |
| (r"linux.*amd64|linux.*x86_64", "linux", "x86_64"), |
| (r"linux.*arm64|linux.*aarch64", "linux", "arm64"), |
| (r"linux.*arm\b", "linux", "arm"), |
| (r"linux.*386\b|linux.*x86\b", "linux", "x86"), |
| (r"linux", "linux", "x86_64"), |
| |
| (r"checksums?|sha256|md5|hash", "all", "all"), |
| ] |
|
|
| def _detect_platform(filename: str) -> tuple[str, str]: |
| """Return (os_name, arch) for a release asset filename.""" |
| lower = filename.lower() |
| for pattern, platform, arch in _OS_RULES: |
| if re.search(pattern, lower): |
| return platform, arch |
| return "unknown", "unknown" |
|
|
|
|
| |
| app = FastAPI( |
| title="DrivePurge Release Automation", |
| description=( |
| "Automates version.json updates, release scanning, " |
| "per-OS asset mirroring, and metadata JSON generation " |
| "for the **DrivePurge_** GitHub repository.\n\n" |
| "Deploy on Hugging Face Spaces (Docker, port 7860) or run locally." |
| ), |
| version="1.0.0", |
| docs_url="/docs", |
| redoc_url="/redoc", |
| ) |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| |
| class UpdateFileRequest(BaseModel): |
| path: str = Field(..., example="version.json") |
| content: dict | list | str = Field(..., description="dict/list β JSON-serialised; str β raw") |
| message: str = Field(..., example="chore: bump version") |
| branch: str = Field("main") |
|
|
| class TriggerRequest(BaseModel): |
| version: str = Field(..., example="1.1") |
| download_assets: bool = Field(True, description="Stream assets to disk?") |
| branch: str = Field("main") |
|
|
| class ProcessReleaseRequest(BaseModel): |
| download_assets: bool = Field(True) |
|
|
| |
|
|
| async def gh_get(client: httpx.AsyncClient, path: str) -> dict | list: |
| r = await client.get(f"{BASE_URL}{path}", headers=_gh_headers()) |
| if r.status_code == 404: |
| raise HTTPException(404, f"Not found: {path}") |
| if r.status_code not in (200, 201): |
| raise HTTPException(r.status_code, f"GitHub API: {r.text[:300]}") |
| return r.json() |
|
|
| async def gh_put(client: httpx.AsyncClient, path: str, payload: dict) -> dict: |
| r = await client.put(f"{BASE_URL}{path}", headers=_gh_headers(), json=payload) |
| if r.status_code not in (200, 201): |
| raise HTTPException(r.status_code, f"GitHub API: {r.text[:300]}") |
| return r.json() |
|
|
| def _b64(content: dict | list | str) -> str: |
| raw = json.dumps(content, indent=2) if isinstance(content, (dict, list)) else str(content) |
| return base64.b64encode(raw.encode()).decode() |
|
|
| async def _file_sha(client: httpx.AsyncClient, path: str, branch: str) -> Optional[str]: |
| try: |
| d = await gh_get(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/contents/{path}?ref={branch}") |
| return d.get("sha") |
| except HTTPException as e: |
| if e.status_code == 404: |
| return None |
| raise |
|
|
| def _slug(tag: str) -> str: |
| return re.sub(r"[^a-zA-Z0-9_\-]", "_", tag.lstrip("v")) |
|
|
| |
|
|
| def _asset_entry(asset: dict, local_path: Optional[str] = None) -> dict: |
| """Build one asset record with full OS/arch tagging.""" |
| platform, arch = _detect_platform(asset["name"]) |
| return { |
| "name": asset["name"], |
| "os": platform, |
| "arch": arch, |
| "size_bytes": asset["size"], |
| "download_url": asset["browser_download_url"], |
| "content_type": asset["content_type"], |
| "created_at": asset["created_at"], |
| "local_path": local_path, |
| } |
|
|
| def _build_metadata(release: dict, downloaded: list[dict]) -> dict: |
| """Full release metadata JSON with per-OS asset breakdown.""" |
| dl_map = {d["name"]: d["path"] for d in downloaded} |
|
|
| assets = [ |
| _asset_entry(a, dl_map.get(a["name"])) |
| for a in release.get("assets", []) |
| ] |
|
|
| |
| by_os: dict[str, list] = {} |
| for a in assets: |
| by_os.setdefault(a["os"], []).append(a) |
|
|
| return { |
| "repo": f"{REPO_OWNER}/{REPO_NAME}", |
| "tag": release["tag_name"], |
| "name": release["name"], |
| "body": release.get("body", ""), |
| "published_at": release.get("published_at"), |
| "html_url": release["html_url"], |
| "assets": assets, |
| "by_os": by_os, |
| "generated_at": datetime.now(timezone.utc).isoformat(), |
| } |
|
|
| |
|
|
| async def _download_assets(client: httpx.AsyncClient, release: dict, folder: Path) -> list[dict]: |
| folder.mkdir(parents=True, exist_ok=True) |
| results = [] |
| for asset in release.get("assets", []): |
| dest = folder / asset["name"] |
| async with client.stream("GET", asset["browser_download_url"], follow_redirects=True) as resp: |
| resp.raise_for_status() |
| with open(dest, "wb") as f: |
| async for chunk in resp.aiter_bytes(65536): |
| f.write(chunk) |
| results.append({"name": asset["name"], "path": str(dest), "size_bytes": dest.stat().st_size}) |
| return results |
|
|
| |
|
|
| @app.get("/health", summary="Health check", tags=["Misc"]) |
| async def health(): |
| return { |
| "status": "ok", |
| "repo": f"{REPO_OWNER}/{REPO_NAME}", |
| "output": str(OUTPUT_DIR), |
| } |
|
|
|
|
| @app.post("/update-file", summary="Create or update a file in the repo", tags=["Repo"]) |
| async def update_file(req: UpdateFileRequest): |
| async with httpx.AsyncClient(timeout=30) as client: |
| sha = await _file_sha(client, req.path, req.branch) |
| payload: dict = { |
| "message": req.message, |
| "content": _b64(req.content), |
| "branch": req.branch, |
| } |
| if sha: |
| payload["sha"] = sha |
| result = await gh_put(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/contents/{req.path}", payload) |
|
|
| return { |
| "action": "updated" if sha else "created", |
| "path": req.path, |
| "branch": req.branch, |
| "commit": result.get("commit", {}).get("sha"), |
| "html_url": result.get("content", {}).get("html_url"), |
| } |
|
|
|
|
| @app.get("/releases", summary="List all releases with per-OS asset URLs", tags=["Releases"]) |
| async def list_releases(): |
| async with httpx.AsyncClient(timeout=30) as client: |
| releases = await gh_get(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/releases") |
|
|
| out = [] |
| for r in releases: |
| out.append({ |
| "tag": r["tag_name"], |
| "name": r["name"], |
| "published_at": r.get("published_at"), |
| "html_url": r["html_url"], |
| "assets": [_asset_entry(a) for a in r.get("assets", [])], |
| }) |
| return {"count": len(out), "releases": out} |
|
|
|
|
| @app.get("/releases/{tag}", summary="Get one release with full per-OS asset detail", tags=["Releases"]) |
| async def get_release(tag: str): |
| async with httpx.AsyncClient(timeout=30) as client: |
| release = await gh_get(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/releases/tags/{tag}") |
|
|
| assets = [_asset_entry(a) for a in release.get("assets", [])] |
| by_os: dict[str, list] = {} |
| for a in assets: |
| by_os.setdefault(a["os"], []).append(a) |
|
|
| return { |
| "tag": release["tag_name"], |
| "name": release["name"], |
| "body": release.get("body"), |
| "published_at": release.get("published_at"), |
| "html_url": release["html_url"], |
| "assets": assets, |
| "by_os": by_os, |
| } |
|
|
|
|
| @app.post( |
| "/process-release/{tag}", |
| summary="Download assets β release{ver}/ folder β write release{ver}.json", |
| tags=["Releases"], |
| ) |
| async def process_release(tag: str, req: ProcessReleaseRequest = ProcessReleaseRequest()): |
| slug = _slug(tag) |
| folder = OUTPUT_DIR / f"release{slug}" |
|
|
| async with httpx.AsyncClient(timeout=600, follow_redirects=True) as client: |
| release = await gh_get(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/releases/tags/{tag}") |
| downloaded = await _download_assets(client, release, folder) if req.download_assets else [] |
| if not req.download_assets: |
| folder.mkdir(parents=True, exist_ok=True) |
|
|
| metadata = _build_metadata(release, downloaded) |
| json_path = folder / f"release{slug}.json" |
| json_path.write_text(json.dumps(metadata, indent=2)) |
|
|
| return { |
| "release_folder": str(folder), |
| "assets_downloaded": len(downloaded), |
| "metadata_json": str(json_path), |
| "metadata": metadata, |
| } |
|
|
|
|
| @app.post( |
| "/trigger", |
| summary="Full pipeline: update version.json β all releases β download assets β write JSON", |
| tags=["Pipeline"], |
| ) |
| async def trigger(req: TriggerRequest): |
| results: dict = {} |
|
|
| async with httpx.AsyncClient(timeout=600, follow_redirects=True) as client: |
|
|
| |
| new_vj = { |
| "version": req.version, |
| "updated_at": datetime.now(timezone.utc).isoformat(), |
| "repo": f"{REPO_OWNER}/{REPO_NAME}", |
| "release_page": f"https://github.com/{REPO_OWNER}/{REPO_NAME}/releases/tag/v{req.version}", |
| } |
| sha = await _file_sha(client, "version.json", req.branch) |
| payload: dict = { |
| "message": f"chore: bump version to {req.version}", |
| "content": _b64(new_vj), |
| "branch": req.branch, |
| } |
| if sha: |
| payload["sha"] = sha |
| cr = await gh_put(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/contents/version.json", payload) |
| results["version_json"] = { |
| "action": "updated" if sha else "created", |
| "commit": cr.get("commit", {}).get("sha"), |
| "html_url": cr.get("content", {}).get("html_url"), |
| } |
|
|
| |
| releases = await gh_get(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/releases") |
| results["releases_found"] = len(releases) |
| results["releases"] = [] |
|
|
| for release in releases: |
| tag = release["tag_name"] |
| slug = _slug(tag) |
| folder = OUTPUT_DIR / f"release{slug}" |
|
|
| downloaded = ( |
| await _download_assets(client, release, folder) |
| if req.download_assets else [] |
| ) |
| if not req.download_assets: |
| folder.mkdir(parents=True, exist_ok=True) |
|
|
| metadata = _build_metadata(release, downloaded) |
| json_path = folder / f"release{slug}.json" |
| json_path.write_text(json.dumps(metadata, indent=2)) |
|
|
| results["releases"].append({ |
| "tag": tag, |
| "folder": str(folder), |
| "assets_downloaded": len(downloaded), |
| "json_written": str(json_path), |
| "os_breakdown": {k: len(v) for k, v in metadata["by_os"].items()}, |
| }) |
|
|
| return results |
|
|
|
|
| |
| if __name__ == "__main__": |
| import uvicorn |
| uvicorn.run( |
| "main:app", |
| host="0.0.0.0", |
| port=int(os.getenv("PORT", "7860")), |
| reload=False, |
| ) |