From 34bec8f4cedc4b7296201e714c226c1fac6b323d Mon Sep 17 00:00:00 2001 From: Sochen Date: Sat, 7 Mar 2026 08:12:53 +0000 Subject: [PATCH] feat: add release.py for automated Forgejo releases Creates git tag, Forgejo release, uploads all 12 deck variants. Supports --dry-run, --validate, --force for re-uploading. Co-Authored-By: Claude Opus 4.6 --- release.py | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 release.py diff --git a/release.py b/release.py new file mode 100644 index 0000000..8b5a74b --- /dev/null +++ b/release.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +Create a Forgejo release and upload all .apkg deck variants. + +Usage: + python3 release.py # uses RELEASE_TAG from apkg_builder.py + python3 release.py v0.14 # explicit tag + python3 release.py --dry-run # show what would be uploaded without doing it + python3 release.py --validate # run validate_apkg.py first, abort on failure + +Requires: + FORGEJO_TOKEN env var or hardcoded token below. + Git tag must not already exist (creates tag + release). +""" + +import argparse +import subprocess +import sys +from pathlib import Path + +import requests + +REPO_API = "https://git.nevo.engineer/api/v1/repos/nevo/hebrew_flash_cards" +FORGEJO_TOKEN = "f023bd4cfd4b77aac584647f2fa8481df3906578" +OUTPUT_DIR = Path(__file__).parent / "output" + +# All deck variants to include in release +DECK_PREFIX = "hebrew_" +DECK_VARIANTS = [ + "hebrew_vocabulary.apkg", + "hebrew_vocabulary_audio.apkg", + "hebrew_vocabulary_images.apkg", + "hebrew_vocabulary_audio_images.apkg", + "hebrew_conjugations.apkg", + "hebrew_conjugations_audio.apkg", + "hebrew_confusables.apkg", + "hebrew_confusables_audio.apkg", + "hebrew_plurals.apkg", + "hebrew_plurals_audio.apkg", + "hebrew_complete.apkg", + "hebrew_complete_audio.apkg", +] + + +def get_release_tag() -> str: + """Import RELEASE_TAG from apkg_builder.""" + sys.path.insert(0, str(Path(__file__).parent)) + from apkg_builder import RELEASE_TAG + + return RELEASE_TAG + + +def api(method: str, endpoint: str, **kwargs) -> requests.Response: + url = f"{REPO_API}{endpoint}" + headers = {"Authorization": f"token {FORGEJO_TOKEN}"} + resp = requests.request(method, url, headers=headers, timeout=30, **kwargs) + resp.raise_for_status() + return resp + + +def tag_exists(tag: str) -> bool: + try: + api("GET", f"/tags/{tag}") + return True + except requests.HTTPError: + return False + + +def release_exists(tag: str) -> dict | None: + try: + resp = api("GET", f"/releases/tags/{tag}") + return resp.json() + except requests.HTTPError: + return None + + +def create_git_tag(tag: str) -> None: + subprocess.run(["git", "tag", tag], check=True) + subprocess.run(["git", "push", "origin", tag], check=True) + print(f" Created and pushed tag: {tag}") + + +def create_release(tag: str, assets: list[Path]) -> int: + """Create release, return release ID.""" + # Build release body from deck file sizes + lines = ["## Deck Variants\n", "| File | Size |", "|------|------|"] + for p in sorted(assets): + size_mb = p.stat().st_size / 1_048_576 + lines.append(f"| {p.name} | {size_mb:.1f} MB |") + + body = "\n".join(lines) + data = { + "tag_name": tag, + "name": f"{tag} — Hebrew Flash Cards", + "body": body, + "draft": False, + "prerelease": False, + } + resp = api("POST", "/releases", json=data) + release_id = resp.json()["id"] + print(f" Created release: {tag} (ID {release_id})") + return release_id + + +def delete_release_assets(release_id: int) -> int: + """Delete all existing assets on a release. Returns count deleted.""" + resp = api("GET", f"/releases/{release_id}/assets") + assets = resp.json() + for asset in assets: + api("DELETE", f"/releases/{release_id}/assets/{asset['id']}") + return len(assets) + + +def upload_assets(release_id: int, assets: list[Path]) -> None: + for p in sorted(assets): + size_mb = p.stat().st_size / 1_048_576 + print(f" Uploading {p.name} ({size_mb:.1f} MB) ... ", end="", flush=True) + with open(p, "rb") as f: + api( + "POST", + f"/releases/{release_id}/assets?name={p.name}", + files={"attachment": (p.name, f, "application/octet-stream")}, + ) + print("ok") + + +def validate_decks() -> bool: + """Run validate_apkg.py, return True if all checks pass.""" + result = subprocess.run( + [sys.executable, "validate_apkg.py"], + capture_output=True, + text=True, + ) + print(result.stdout) + if result.returncode != 0: + print(result.stderr) + return result.returncode == 0 + + +def main(): + parser = argparse.ArgumentParser(description="Create Forgejo release with deck assets") + parser.add_argument("tag", nargs="?", help="Release tag (default: from apkg_builder.RELEASE_TAG)") + parser.add_argument("--dry-run", action="store_true", help="Show what would be done without doing it") + parser.add_argument("--validate", action="store_true", help="Run validate_apkg.py before releasing") + parser.add_argument("--force", action="store_true", help="Re-upload assets if release already exists") + args = parser.parse_args() + + tag = args.tag or get_release_tag() + print(f"Release tag: {tag}") + + # Collect assets + assets = [OUTPUT_DIR / name for name in DECK_VARIANTS] + missing = [p for p in assets if not p.exists()] + if missing: + print("\nERROR: Missing deck files:") + for p in missing: + print(f" {p}") + print("\nRun the build pipeline first: python3 run.py --skip-scrape") + sys.exit(1) + + print(f"Assets: {len(assets)} deck files") + total_mb = sum(p.stat().st_size for p in assets) / 1_048_576 + print(f"Total size: {total_mb:.0f} MB") + + if args.validate: + print("\nValidating decks ...") + if not validate_decks(): + print("ERROR: Validation failed. Aborting release.") + sys.exit(1) + print("Validation passed.\n") + + if args.dry_run: + print("\n[DRY RUN] Would upload:") + for p in sorted(assets): + size_mb = p.stat().st_size / 1_048_576 + print(f" {p.name} ({size_mb:.1f} MB)") + print(f"\n[DRY RUN] Tag: {tag}") + return + + # Check if release already exists + existing = release_exists(tag) + if existing and not args.force: + print(f"\nRelease {tag} already exists (ID {existing['id']}).") + print("Use --force to delete existing assets and re-upload.") + sys.exit(1) + + if existing and args.force: + release_id = existing["id"] + deleted = delete_release_assets(release_id) + print(f" Deleted {deleted} existing assets from release {tag}") + else: + # Create tag if needed + if not tag_exists(tag): + create_git_tag(tag) + release_id = create_release(tag, assets) + + # Upload + print(f"\nUploading {len(assets)} files ...") + upload_assets(release_id, assets) + + print(f"\nDone. Release: https://git.nevo.engineer/nevo/hebrew_flash_cards/releases/tag/{tag}") + + +if __name__ == "__main__": + main()