Skip to main content
  1. Posts/

Building a Self-Hosted Expo OTA Update Server with Django and Tigris

· loading · loading ·
Jared Lynskey
Author
Jared Lynskey
Emerging leader and software engineer based in Seoul, South Korea

My app, Curtain Estimator, gets its bug fixes pushed over the air: JavaScript changes land on users’ phones without going anywhere near App Store review. Expo’s hosted service, EAS Update, will happily do this for you. I built my own version anyway, with Django REST Framework and Tigris S3, and it has served every iOS and Android update since.

This post walks through the whole thing: the models, the manifest endpoint, the publishing pipeline, and the gotchas that only show up once you’re running it yourself.

Why self-host at all?
#

Mostly cost, in my case. EAS Update pricing scales with usage, so pushing frequent updates to a growing user base adds up, while S3-compatible storage like Tigris stays nearly free. There are other reasons that might weigh more for you: some industries have to keep every application asset inside their own infrastructure, and owning the server means owning the update flow, so you can roll out to specific user segments or A/B test bundles if your product calls for it. It also means nothing about your update pipeline depends on Expo’s availability or their next pricing change.

How the pieces fit
#

Four components: a Django backend that serves update manifests and stores metadata, Tigris holding the actual bundle and asset files, a publishing script that exports, uploads and registers updates, and the mobile app itself pointed at my server instead of Expo’s.

┌─────────────────┐
│   Mobile App    │
│  (expo-updates) │
└────────┬────────┘
         │ 1. Request manifest
         │    (with headers: platform, runtime-version)
┌─────────────────┐
│  Django Server  │
│  /api/expo-     │◄─── 2. Query DB for latest update
│   updates/      │
│   manifest/     │
└────────┬────────┘
         │ 3. Generate presigned URLs
┌─────────────────┐
│   Tigris S3     │
│  (Asset Files)  │◄─── 4. App downloads bundles directly
└─────────────────┘

Building it
#

The two models
#

Everything hangs off ExpoUpdate and ExpoUpdateAsset. The first stores metadata for each update:

class ExpoUpdate(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    runtime_version = models.CharField(max_length=50, db_index=True)
    platform = models.CharField(
        max_length=10,
        choices=[("ios", "iOS"), ("android", "Android")],
        db_index=True
    )
    is_active = models.BooleanField(default=True, db_index=True)
    manifest_data = models.JSONField()
    description = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=["runtime_version", "platform", "is_active", "-created_at"])
        ]

A few deliberate choices in there. runtime_version mirrors the runtimeVersion in app.json, and it does a lot of work: clients only ever download updates matching their own runtime version. iOS and Android get separate rows because the bundles differ. is_active is the rollback mechanism; deactivate a bad update and clients fall back to the previous one. And manifest_data holds the complete Expo Updates v1 protocol manifest as JSON, so serving it later is just a lookup.

ExpoUpdateAsset tracks the individual files:

class ExpoUpdateAsset(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    update = models.ForeignKey(ExpoUpdate, on_delete=models.CASCADE, related_name="assets")
    hash = models.CharField(max_length=255, db_index=True)
    key = models.CharField(max_length=255)
    content_type = models.CharField(max_length=100)
    file_extension = models.CharField(max_length=10)
    file_path = models.CharField(max_length=500)
    file_size = models.IntegerField(default=0)

Assets are keyed by SHA-256 hash, which makes them immutable and cacheable. The same asset can be shared across any number of updates.

The manifest endpoint
#

/api/expo-updates/manifest/ is where app and server actually talk. It implements the Expo Updates v1 protocol:

@action(detail=False, methods=["get"], url_path="manifest")
def manifest(self, request):
    # Extract required headers
    protocol_version = request.META.get("HTTP_EXPO_PROTOCOL_VERSION")
    platform = request.META.get("HTTP_EXPO_PLATFORM")
    runtime_version = request.META.get("HTTP_EXPO_RUNTIME_VERSION")

    # Validate protocol version
    if protocol_version != "1":
        return Response(
            {"error": f"Unsupported protocol version: {protocol_version}"},
            status=400
        )

    # Find latest active update for this runtime + platform
    update = ExpoUpdate.objects.filter(
        runtime_version=runtime_version,
        platform=platform,
        is_active=True,
    ).order_by("-created_at").first()

    # No update available - client uses embedded bundle
    if not update:
        response = Response(status=204)
        response["expo-protocol-version"] = "1"
        return response

    # Generate presigned URLs for all assets
    manifest_data = self._generate_manifest_with_presigned_urls(update)

    return Response(manifest_data, status=200)

Three details matter here. A 204 response means “no update”, and the app carries on with its embedded bundle. The asset URLs in the manifest are presigned, so the app downloads straight from Tigris’s CDN rather than streaming everything through Django. And the expo-protocol-version response header isn’t optional; the client checks for it.

Publishing updates
#

Publishing is a Django management command with a shell script wrapped around it. The command, publish_expo_update.py, reads the output of expo export, hashes every asset with SHA-256, uploads bundles and assets to Tigris in parallel, writes the database records, and optionally drops an import JSON into the bucket for production sync. The core flow:

def _publish_platform(self, platform, runtime_version, export_dir, ...):
    # 1. Find the bundle file
    bundle_files = list(bundle_dir.glob("entry-*.hbc"))
    bundle_file = bundle_files[0]

    # 2. Calculate hash
    with open(bundle_file, "rb") as f:
        bundle_content = f.read()
    bundle_hash = self._calculate_hash(bundle_content)

    # 3. Collect all assets and their hashes
    for asset_file in assets_dir.rglob("*"):
        # Calculate hash, determine content type...
        assets_metadata.append({...})

    # 4. Upload to S3 in parallel
    with ThreadPoolExecutor(max_workers=10) as executor:
        futures = {executor.submit(upload_asset, a): a for a in assets_metadata}

    # 5. Create database records
    with transaction.atomic():
        # Deactivate previous updates
        ExpoUpdate.objects.filter(
            runtime_version=runtime_version,
            platform=platform,
            is_active=True
        ).update(is_active=False)

        # Create new update
        update = ExpoUpdate.objects.create(...)

The wrapper script, publish-ota-update.sh, is what I actually type:

# Publish to local environment
./scripts/publish-ota-update.sh ios

# Publish to production
./scripts/publish-ota-update.sh ios --production

# Dry run to validate
./scripts/publish-ota-update.sh --dry-run

It loads production environment variables before exporting, handles one platform or both, and takes care of the production sync described next. The full script is at the bottom of this post.

Syncing to production
#

I didn’t want production credentials sitting on my laptop, so production deploys happen in two steps: publish locally (assets go to Tigris, along with a JSON snapshot of the metadata), then call a production API endpoint with the S3 path so the server imports the metadata itself:

@action(detail=False, methods=["post"], url_path="import-update")
def import_update(self, request):
    # Authenticate via Bearer token
    secret = settings.OTA_IMPORT_SECRET
    token = request.META.get("HTTP_AUTHORIZATION", "")[7:]  # Strip "Bearer "
    if not hmac.compare_digest(token, secret):
        return Response({"error": "Invalid token"}, status=401)

    # Download import JSON from Tigris
    s3_key = request.data.get("s3_key")
    obj = s3_client.get_object(Bucket=bucket_name, Key=s3_key)
    data = json.loads(obj["Body"].read())

    # Import to production database
    with transaction.atomic():
        ExpoUpdate.objects.update_or_create(id=data["id"], defaults={...})
        for asset_data in data["assets"]:
            ExpoUpdateAsset.objects.update_or_create(...)

    # Clean up the import JSON
    s3_client.delete_object(Bucket=bucket_name, Key=s3_key)

Pointing the app at your server
#

In app.json, set the update URL and runtime version:

{
  "expo": {
    "runtimeVersion": "1.0.0",
    "updates": {
      "url": "https://your-server.com/api/expo-updates/manifest/"
    }
  }
}

Be strict about keeping runtimeVersion matched between app and server. When you change native code or upgrade the Expo SDK, bump the runtime version and publish fresh updates for it.

Storage on Tigris
#

Tigris is S3-compatible object storage, considerably cheaper than actual AWS S3, and it includes global edge caching, which matters when users are pulling down multi-megabyte bundles. The Django configuration:

# settings.py
BUCKET_NAME = os.getenv("BUCKET_NAME")
AWS_ENDPOINT_URL_S3 = os.getenv("AWS_ENDPOINT_URL_S3")
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
AWS_REGION = os.getenv("AWS_REGION", "auto")

Creating the client is standard boto3:

import boto3

def create_s3_client(endpoint_url, region, access_key, secret_key):
    return boto3.client(
        "s3",
        endpoint_url=endpoint_url,
        region_name=region,
        aws_access_key_id=access_key,
        aws_secret_access_key=secret_key,
    )

Downloads go through presigned URLs, so the app fetches straight from the CDN:

presigned_url = s3_client.generate_presigned_url(
    "get_object",
    Params={"Bucket": bucket_name, "Key": asset.file_path},
    ExpiresIn=3600,  # 1 hour
)

Security
#

The manifest endpoint is unauthenticated on purpose, because apps need updates before anyone has logged in. The import endpoint is a different story. It can write to the production database, so it wants a shared secret, compared in constant time to rule out timing attacks:

OTA_IMPORT_SECRET = os.getenv("OTA_IMPORT_SECRET")

# Constant-time comparison prevents timing attacks
if not hmac.compare_digest(token, secret):
    return Response({"error": "Invalid token"}, status=401)

Asset integrity comes free with the design: everything is verified against its SHA-256 hash, so a tampered asset simply fails. And presigned URLs expire after an hour, which keeps bundles from being hotlinked indefinitely.

Keeping it fast
#

The composite index on (runtime_version, platform, is_active, -created_at) keeps the manifest query quick no matter how many updates accumulate:

class Meta:
    indexes = [
        models.Index(fields=["runtime_version", "platform", "is_active", "-created_at"])
    ]

The publishing script uploads assets concurrently:

with ThreadPoolExecutor(max_workers=10) as executor:
    futures = {executor.submit(upload_asset, asset): asset for asset in assets}
    for future in as_completed(futures):
        # Track progress

For a typical update with 50 or so assets, that took publishing from about 2 minutes down to 15 seconds. Distribution needs no work at all: Tigris caches assets at edge locations near your users on its own.

Day-to-day workflow
#

During development
#

# 1. Make code changes in mobile app
cd mobile-app && git commit -am "Fix bug"

# 2. Publish OTA update to local environment
yarn publish-update:ios

# 3. Test on device
# App automatically downloads and applies update

Shipping to production
#

# 1. Publish to production
yarn publish-update:prod:ios

# 2. Monitor
# Check Django admin for update records
# Verify assets in Tigris dashboard

Rolling back
#

# Mark problematic update as inactive in Django admin
# or via management shell:
python manage.py shell

>>> from jobs.models import ExpoUpdate
>>> bad_update = ExpoUpdate.objects.get(id="uuid-here")
>>> bad_update.is_active = False
>>> bad_update.save()

# Clients will now receive the previous active update

What it costs
#

Curtain Estimator has around 500 active users. On Tigris, the ~200 MB of historical updates costs about $0.02/month, and the ~50 GB of monthly egress from update downloads about $1.00. The Django side rides along on the Fly.io instance my API already runs on, so its incremental cost is zero. Call it a dollar a month, all in. EAS Update at similar usage would land somewhere around $300–500/year, so this paid for itself more or less immediately.

Monitoring and debugging
#

Nothing fancy here. The viewset logs every manifest request:

logger.info(f"Manifest request: platform={platform}, runtime={runtime_version}")

The Django admin is the de facto dashboard. Register the models and you can inspect and filter everything:

@admin.register(ExpoUpdate)
class ExpoUpdateAdmin(admin.ModelAdmin):
    list_display = ["platform", "runtime_version", "is_active", "created_at"]
    list_filter = ["platform", "is_active", "runtime_version"]
    search_fields = ["description"]

And on the client, expo-updates will tell you what it sees:

import * as Updates from 'expo-updates';

Updates.checkForUpdateAsync().then(update => {
  console.log('Update available:', update.isAvailable);
  console.log('Manifest:', update.manifest);
});

Gotchas
#

Runtime version mismatches
#

The most common failure is also the quietest: clients only download updates matching their runtime version. If the installed app is on runtime 1.0.0 and you publish for 1.0.1, nothing arrives and nothing errors. Keep the runtime version in sync with your builds, and only bump it when native code changes.

The createdAt timestamp
#

The expo-updates client compares the manifest’s createdAt against the embedded bundle’s commitTime, and only applies an update whose createdAt is newer. The viewset overrides it with the database timestamp:

manifest_data["createdAt"] = update.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ")

For local development, if you build a binary after publishing an OTA, republish the OTA so its timestamp wins.

Manifest asset key values
#

The key field on each manifest asset is what expo-updates uses for caching, and it must be a deterministic hash (the MD5 of the filename works), not a random UUID or arbitrary string. Get this wrong and the client may fail to cache or retrieve assets correctly, and updates silently break after the first successful load. Thanks to reader Raphael Mutschler for flagging this one; he had updates working exactly once before discovering the issue.

App config in expoClient
#

If your app uses Linking, Constants, or anything else that reads the app config at runtime, the manifest’s extra.expoClient field needs to include your app config. Without it the app may launch fine at first, then crash or refuse to reopen after being closed. That’s because expo-updates replaces the embedded manifest with the OTA one, and if expoClient is missing, those APIs lose the config they depend on. Raphael Mutschler caught this one too.

Asset cleanup
#

Old updates pile up in Tigris. For now, cleanup at my end is manual:

# Delete updates older than 30 days
from datetime import timedelta
from django.utils import timezone

cutoff = timezone.now() - timedelta(days=30)
old_updates = ExpoUpdate.objects.filter(created_at__lt=cutoff, is_active=False)

for update in old_updates:
    # Delete assets from S3
    for asset in update.assets.all():
        s3_client.delete_object(Bucket=bucket_name, Key=asset.file_path)
    # Delete DB records
    update.delete()

A scheduled task is the obvious home for this. I haven’t got round to it.

Things I’d add next
#

Gradual rollouts
#

A rollout_percentage field would let an update go out to a slice of users first:

rollout_percentage = models.IntegerField(default=100)

# In the manifest view:
if update.rollout_percentage < 100:
    # Hash user ID and check if they're in rollout group
    user_hash = int(hashlib.sha256(user_id.encode()).hexdigest(), 16)
    if (user_hash % 100) >= update.rollout_percentage:
        return Response(status=204)  # No update

Separate staging updates
#

An environment field would let staging builds receive different updates from production:

environment = models.CharField(max_length=20, default="production")

# Client sends environment in custom header
environment = request.META.get("HTTP_X_UPDATE_ENVIRONMENT", "production")
update = ExpoUpdate.objects.filter(environment=environment, ...).first()

Download analytics
#

And a small model would answer the “did anyone actually get this?” question properly:

class ExpoUpdateDownload(models.Model):
    update = models.ForeignKey(ExpoUpdate, on_delete=models.CASCADE)
    user_id = models.CharField(max_length=255, null=True)
    platform = models.CharField(max_length=10)
    downloaded_at = models.DateTimeField(auto_now_add=True)

Wrapping up
#

The whole system is roughly 150 lines of Django models and views, about 200 lines of publishing script, and a dollar a month in infrastructure. That’s less than I expected going in, and it has quietly done its job for Curtain Estimator on both platforms since.

The code above is lifted from that production app, so adapt it to whatever you’re building. Reader Raphael Mutschler also published a standalone implementation at expo-ota-server if you’d rather start from something that already runs.


For the full spec, see the Expo Updates Protocol Specification.


The full scripts
#

The publish script is the one I use daily. The sync script is an older alternative that skips the import API and copies an update record straight into the production database over flyctl ssh.

publish-ota-update.sh
#

#!/bin/bash

# Publish OTA Update Script
#
# Usage:
#   ./scripts/publish-ota-update.sh                    # Both platforms (LOCAL)
#   ./scripts/publish-ota-update.sh ios                # iOS only (LOCAL)
#   ./scripts/publish-ota-update.sh ios --production   # iOS to PRODUCTION
#   ./scripts/publish-ota-update.sh --dry-run          # Test without uploading
#   ./scripts/publish-ota-update.sh ios --description "Bug fixes"

set -e

GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
RED='\033[0;31m'
NC='\033[0m'

# Parse arguments
PLATFORM="all"
PRODUCTION=false
DRY_RUN=false
DESCRIPTION=""

while [[ $# -gt 0 ]]; do
    case $1 in
        ios|android|all) PLATFORM="$1"; shift ;;
        --production|--prod) PRODUCTION=true; shift ;;
        --dry-run) DRY_RUN=true; shift ;;
        --description) DESCRIPTION="$2"; shift 2 ;;
        -h|--help)
            echo "Usage: $0 [ios|android|all] [--production] [--dry-run] [--description \"msg\"]"
            exit 0 ;;
        *) echo -e "${RED}Unknown: $1${NC}"; exit 1 ;;
    esac
done

# Auto-detect project root (support running from mobile-app/ via yarn)
if [ -d "mobile-app" ]; then
    : # already at project root
elif [ -d "../mobile-app" ]; then
    cd ..
else
    echo -e "${RED}Error: Run from project root or mobile-app/${NC}" && exit 1
fi
docker info > /dev/null 2>&1 || { echo -e "${RED}Error: Docker not running${NC}"; exit 1; }

# Log file — verbose output goes here, terminal gets summary only
LOG_FILE="ota-publish-$(date +%Y%m%d-%H%M%S).log"

echo -e "${BLUE}═══ Expo OTA Publisher ═══${NC}"
echo -e "Platform: ${PLATFORM}  Production: ${PRODUCTION}  Log: ${LOG_FILE}"
echo ""

# ── Step 1: Export with production env vars ──
echo -e "${YELLOW}Step 1: Exporting mobile app...${NC}"
cd mobile-app

# Load production env vars from eas.json (adapt these to your app's env vars)
if [ -f "eas.json" ] && command -v jq &> /dev/null; then
    for key in $(jq -r '.build.production.env // {} | keys[]' eas.json); do
        export "$key"="$(jq -r ".build.production.env.$key" eas.json)"
    done
fi

OTA_EXPORT_DIR="dist-ota"
if [ "$PLATFORM" = "all" ]; then
    npx expo export --platform ios --output-dir "$OTA_EXPORT_DIR" >> "../$LOG_FILE" 2>&1
    npx expo export --platform android --output-dir "$OTA_EXPORT_DIR" >> "../$LOG_FILE" 2>&1
else
    npx expo export --platform "$PLATFORM" --output-dir "$OTA_EXPORT_DIR" >> "../$LOG_FILE" 2>&1
fi

cd ..
echo -e "${GREEN}✓ Export complete${NC}"

# ── Step 2: Upload to Tigris + create DB records ──
echo -e "${YELLOW}Step 2: Publishing to Tigris...${NC}"

CMD_ARGS="--platform $PLATFORM --export-dir mobile-app/$OTA_EXPORT_DIR"
[ "$DRY_RUN" = true ] && CMD_ARGS="$CMD_ARGS --dry-run"
[ -n "$DESCRIPTION" ] && CMD_ARGS="$CMD_ARGS --description \"$DESCRIPTION\""
[ "$PRODUCTION" = true ] && CMD_ARGS="$CMD_ARGS --production-sync"

PUBLISH_OUTPUT=$(eval docker compose exec -T django python manage.py publish_expo_update $CMD_ARGS 2>&1)
echo "$PUBLISH_OUTPUT" >> "$LOG_FILE"

# Print key lines to terminal
echo "$PUBLISH_OUTPUT" | grep -E '✓ Published:|Deactivated|OTA_S3_KEY=|DRY RUN|ERROR|Failed' || true

# ── Step 3 (production only): Sync via API endpoint ──
if [ "$PRODUCTION" = true ] && [ "$DRY_RUN" = false ]; then
    echo -e "${YELLOW}Step 3: Syncing to production...${NC}"

    # Extract S3 key(s) from management command output
    S3_KEYS=$(echo "$PUBLISH_OUTPUT" | grep -o 'OTA_S3_KEY=[^ ]*' | sed 's/OTA_S3_KEY=//')
    [ -z "$S3_KEYS" ] && echo -e "${RED}Error: No OTA_S3_KEY found in publish output${NC}" && exit 1

    # Read OTA_IMPORT_SECRET from .env
    if [ -f ".env" ]; then
        OTA_IMPORT_SECRET=$(grep -E '^OTA_IMPORT_SECRET=' .env | sed 's/^OTA_IMPORT_SECRET=//')
    fi
    [ -z "$OTA_IMPORT_SECRET" ] && echo -e "${RED}Error: OTA_IMPORT_SECRET not found in .env${NC}" && exit 1

    PROD_URL="https://your-app.fly.dev/api/expo-updates/import-update/"

    for S3_KEY in $S3_KEYS; do
        RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$PROD_URL" \
            -H "Authorization: Bearer $OTA_IMPORT_SECRET" \
            -H "Content-Type: application/json" \
            -d "{\"s3_key\": \"$S3_KEY\"}")

        HTTP_CODE=$(echo "$RESPONSE" | tail -1)
        BODY=$(echo "$RESPONSE" | sed '$d')
        echo "$BODY" >> "$LOG_FILE"

        if [ "$HTTP_CODE" = "200" ]; then
            UPDATE_ID=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['update_id'])" 2>/dev/null || echo "unknown")
            PLAT=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['platform'])" 2>/dev/null || echo "unknown")
            NOTIF_COUNT=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('notifications_sent', 0))" 2>/dev/null || echo "0")
            echo -e "${GREEN}${PLAT}: ${UPDATE_ID}${NC}"
            echo -e "${GREEN}✓ Sent ${NOTIF_COUNT} push notification(s) to production users${NC}"
        else
            echo -e "${RED}Error: HTTP $HTTP_CODE${NC}"
            echo "$BODY"
            exit 1
        fi
    done
fi

echo ""
echo -e "${GREEN}═══ ✓ Done ═══${NC}"
echo -e "Full log: ${LOG_FILE}"

sync-ota-to-prod.sh
#

#!/bin/bash

# Sync OTA Update to Production Database
# This script copies an OTA update record from local to production database
# The bundles are already in Tigris (shared between local and production)

set -e

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

# Get the update ID from arguments or use the latest
UPDATE_ID="$1"

if [ -z "$UPDATE_ID" ]; then
    echo -e "${YELLOW}No update ID provided, using latest iOS update...${NC}"
    UPDATE_ID=$(docker compose exec -T django python manage.py shell -c "
from jobs.models import ExpoUpdate
update = ExpoUpdate.objects.filter(platform='ios').order_by('-created_at').first()
print(update.id if update else '')
" | tail -1 | tr -d '\r\n')
fi

echo -e "${BLUE}Syncing OTA Update to Production${NC}"
echo -e "${BLUE}Update ID: $UPDATE_ID${NC}"
echo ""

# Export the update data from local database
echo -e "${YELLOW}Step 1/2: Exporting from local database...${NC}"
docker compose exec -T django python manage.py shell -c "
import json
from jobs.models import ExpoUpdate, ExpoUpdateAsset

try:
    update = ExpoUpdate.objects.get(id='$UPDATE_ID')
except ExpoUpdate.DoesNotExist:
    print('ERROR: Update not found')
    exit(1)

# Export update
print(json.dumps({
    'id': str(update.id),
    'runtime_version': update.runtime_version,
    'platform': update.platform,
    'is_active': update.is_active,
    'manifest_data': update.manifest_data,
    'description': update.description,
    'assets': [
        {
            'id': str(asset.id),
            'hash': asset.hash,
            'key': asset.key,
            'content_type': asset.content_type,
            'file_extension': asset.file_extension,
            'file_path': asset.file_path,
            'file_size': asset.file_size,
        }
        for asset in update.assets.all()
    ]
}))
" > /tmp/ota_sync_$UPDATE_ID.json

# Check if export succeeded
if [ ! -s /tmp/ota_sync_$UPDATE_ID.json ]; then
    echo -e "${RED}Failed to export update${NC}"
    exit 1
fi

echo -e "${GREEN}✓ Exported update data${NC}"
echo ""

# Import to production database
echo -e "${YELLOW}Step 2/2: Importing to production database...${NC}"

# Create Python script for import
cat > /tmp/ota_import.py << 'EOFPY'
import json
from jobs.models import ExpoUpdate, ExpoUpdateAsset

with open('/tmp/ota_data.json', 'r') as f:
    data = json.load(f)

# Create or update the ExpoUpdate record
update, created = ExpoUpdate.objects.update_or_create(
    id=data['id'],
    defaults={
        'runtime_version': data['runtime_version'],
        'platform': data['platform'],
        'is_active': data['is_active'],
        'manifest_data': data['manifest_data'],
        'description': data['description'],
    }
)

print(f"Update: {'created' if created else 'updated'}")
print(f"  ID: {update.id}")
print(f"  Platform: {update.platform}")
print(f"  Runtime: {update.runtime_version}")
print(f"  Description: {update.description}")

# Create assets
assets_created = 0
for asset_data in data['assets']:
    _, created = ExpoUpdateAsset.objects.update_or_create(
        id=asset_data['id'],
        defaults={
            'update': update,
            'hash': asset_data['hash'],
            'key': asset_data['key'],
            'content_type': asset_data['content_type'],
            'file_extension': asset_data['file_extension'],
            'file_path': asset_data['file_path'],
            'file_size': asset_data['file_size'],
        }
    )
    if created:
        assets_created += 1

print(f"Assets: {assets_created} created, {len(data['assets']) - assets_created} updated")
print(f"✓ OTA update successfully synced to production!")
EOFPY

# Copy JSON to temp location and import
flyctl ssh console -C "cat > /tmp/ota_data.json" < /tmp/ota_sync_$UPDATE_ID.json
flyctl ssh console -C "cat > /tmp/ota_import.py" < /tmp/ota_import.py
flyctl ssh console -C "python manage.py shell < /tmp/ota_import.py"

# Cleanup
rm /tmp/ota_sync_$UPDATE_ID.json /tmp/ota_import.py

echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║  ✓ OTA Update Synced to Production!                      ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
echo ""

# Verify
echo -e "${BLUE}Verifying production...${NC}"
flyctl ssh console -C "python manage.py shell -c \"
from jobs.models import ExpoUpdate
count = ExpoUpdate.objects.count()
latest = ExpoUpdate.objects.order_by('-created_at').first()
print(f'Total OTA updates: {count}')
if latest:
    print(f'Latest: {latest.platform} - {latest.description}')
\""