- 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>
208 lines
6.6 KiB
Python
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()
|