hebrew_flash_cards/release.py
Sochen f3496998f5 feat: confusables show ktiv male, emoji/prep stripping fully upstream
- Confusables deck front now shows shared ktiv male form instead of
  nikkud variants joined by "/". Back still shows nikkud with definitions.
- Fixed list scraper EMOJI_RE to catch variation selectors (U+FE0F) and
  ZWJ (U+200D) — cleaned 17 entries with leftover selectors in meaning.
- Removed build-time prep extraction fallback (0 entries relied on it).
- release.py: fix keeshare field name (API_TOKEN → password).

Closes: Pealim #11 (emoji/prep upstream), Pealim #16 (confusables ktiv male)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 02:19:03 +00:00

208 lines
6.6 KiB
Python

#!/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
sys.path.insert(0, "/home/node/projects")
import load_keeshare
REPO_API = "https://git.nevo.engineer/api/v1/repos/nevo/hebrew_flash_cards"
FORGEJO_TOKEN: str = load_keeshare.get_entry("git.nevo.engineer")["password"]
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()