본문으로 건너뛰기
  1. 포스트/

Django와 Tigris로 Expo OTA 업데이트 서버 직접 만들기

· loading · loading ·
인재덕
작성자
인재덕
서울에 거주하는 리더 겸 소프트웨어 엔지니어

제가 만들고 있는 Curtain Estimator 앱은 버그 수정을 OTA(Over-the-Air)로 배포합니다. JavaScript 변경 사항이 App Store 심사를 거치지 않고 바로 사용자 폰에 도착하죠. Expo의 호스팅 서비스인 EAS Update를 쓰면 이걸 알아서 해주지만, 저는 Django REST Framework와 Tigris S3로 서버를 직접 만들었습니다. 그 뒤로 iOS와 Android 업데이트는 전부 이 서버가 처리하고 있습니다.

이 글에서는 모델 설계부터 매니페스트 엔드포인트, 퍼블리시 파이프라인, 그리고 직접 운영해 봐야 알게 되는 함정까지 전체 과정을 정리했습니다.

굳이 직접 호스팅하는 이유
#

제 경우엔 비용이 가장 컸습니다. EAS Update 요금은 사용량에 따라 올라가서, 사용자가 늘고 업데이트가 잦아지면 부담이 됩니다. 반면 Tigris 같은 S3 호환 스토리지는 거의 공짜에 가깝죠. 다른 이유가 더 중요한 경우도 있을 겁니다. 애플리케이션 에셋을 전부 자체 인프라 안에 둬야 하는 업계도 있고, 서버를 직접 운영하면 업데이트 흐름 전체를 손에 쥐게 되니 특정 사용자 그룹에만 배포하거나 번들 A/B 테스트를 하는 것도 자유롭습니다. 업데이트 파이프라인이 Expo의 가용성이나 다음 요금 개편에 좌우되지 않는다는 점도 은근히 큽니다.

전체 구조
#

구성 요소는 넷입니다. 업데이트 매니페스트를 내려주고 메타데이터를 저장하는 Django 백엔드, 번들과 에셋 실물을 보관하는 Tigris, 내보내기·업로드·등록을 한 번에 처리하는 퍼블리시 스크립트, 그리고 Expo 서버 대신 제 서버를 바라보도록 설정한 모바일 앱입니다.

┌─────────────────┐
│   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
└─────────────────┘

구현
#

모델 두 개
#

모든 것의 바탕은 ExpoUpdateExpoUpdateAsset입니다. 전자는 업데이트별 메타데이터를 담습니다.

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"])
        ]

설계할 때 의도한 부분이 몇 가지 있습니다. runtime_versionapp.jsonruntimeVersion에 대응하는데, 클라이언트는 자기 런타임 버전과 일치하는 업데이트만 내려받기 때문에 이 필드가 핵심 역할을 합니다. iOS와 Android는 번들 자체가 다르니 레코드를 분리했고, is_active는 롤백 장치입니다. 문제 있는 업데이트를 비활성화하면 클라이언트는 이전 것으로 돌아갑니다. manifest_data에는 Expo Updates v1 프로토콜 매니페스트 전체를 JSON으로 넣어두기 때문에, 서빙할 때는 꺼내기만 하면 됩니다.

ExpoUpdateAsset은 개별 파일을 추적합니다.

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)

에셋은 SHA-256 해시로 참조하므로 불변이고 캐시하기도 좋습니다. 같은 에셋을 여러 업데이트에서 공유할 수도 있고요.

매니페스트 엔드포인트
#

앱과 서버가 실제로 대화하는 곳이 /api/expo-updates/manifest/입니다. Expo Updates v1 프로토콜을 구현합니다.

@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)

여기서 중요한 게 셋 있습니다. 업데이트가 없으면 204를 반환하고, 앱은 내장 번들을 그대로 씁니다. 매니페스트의 에셋 URL은 presigned URL이라 Django를 거치지 않고 Tigris CDN에서 바로 내려받습니다. 그리고 응답의 expo-protocol-version 헤더는 생략하면 안 됩니다. 클라이언트가 실제로 확인하거든요.

업데이트 퍼블리시
#

퍼블리시는 Django 관리 명령과 이를 감싼 셸 스크립트로 합니다. 관리 명령인 publish_expo_update.pyexpo export 출력을 읽고, 에셋마다 SHA-256 해시를 계산하고, 번들과 에셋을 Tigris에 병렬로 올리고, 데이터베이스 레코드를 만들고, 필요하면 프로덕션 동기화용 임포트 JSON까지 버킷에 올립니다. 핵심 흐름은 이렇습니다.

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(...)

평소에 실제로 치는 명령은 래퍼인 publish-ota-update.sh 쪽입니다.

# 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

내보내기 전에 프로덕션 환경 변수를 불러오고, 플랫폼은 하나만 또는 둘 다 지정할 수 있으며, 다음에 설명할 프로덕션 동기화까지 알아서 해줍니다. 스크립트 전문은 글 맨 아래에 있습니다.

프로덕션 동기화
#

프로덕션 인증 정보를 제 노트북에 두고 싶지 않아서, 프로덕션 배포는 두 단계로 나눴습니다. 먼저 로컬에서 퍼블리시하고(에셋은 Tigris로, 메타데이터 JSON 스냅샷도 함께), 그다음 S3 경로를 담아 프로덕션 API를 호출하면 서버가 직접 메타데이터를 가져옵니다.

@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)

앱이 내 서버를 바라보게 하기
#

app.json에서 업데이트 URL과 런타임 버전을 설정합니다.

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

runtimeVersion은 앱과 서버 간에 엄격하게 맞춰야 합니다. 네이티브 코드를 바꾸거나 Expo SDK를 올렸다면 런타임 버전을 올리고 그 버전용 업데이트를 새로 퍼블리시하세요.

Tigris 스토리지
#

Tigris는 S3 호환 오브젝트 스토리지인데, AWS S3보다 훨씬 저렴하고 글로벌 엣지 캐싱이 기본으로 포함됩니다. 사용자가 수 메가바이트짜리 번들을 내려받는 상황에서는 이게 꽤 중요하죠. Django 쪽 설정은 이렇습니다.

# 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")

클라이언트 생성은 평범한 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,
    )

다운로드는 presigned URL을 거치므로 앱이 CDN에서 바로 받아옵니다.

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

보안
#

매니페스트 엔드포인트는 일부러 인증을 걸지 않았습니다. 로그인하기 전의 앱에도 업데이트는 필요하니까요. 임포트 엔드포인트는 이야기가 다릅니다. 프로덕션 데이터베이스에 쓸 수 있는 만큼 공유 시크릿으로 보호하고, 타이밍 공격을 막기 위해 상수 시간 비교를 씁니다.

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)

에셋 무결성은 이 설계에서 공짜로 따라옵니다. 모든 것이 SHA-256 해시로 검증되니 변조된 에셋은 그냥 실패할 뿐입니다. presigned URL도 1시간이면 만료돼서 번들이 계속 직링크로 노출될 일이 없습니다.

속도
#

(runtime_version, platform, is_active, -created_at) 복합 인덱스 덕분에 업데이트가 아무리 쌓여도 매니페스트 쿼리는 빠르게 유지됩니다.

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

퍼블리시 스크립트는 에셋을 병렬로 업로드합니다.

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

에셋 50개 정도의 일반적인 업데이트 기준으로, 퍼블리시 시간이 2분 남짓에서 15초로 줄었습니다. 배포 쪽은 할 일이 없습니다. Tigris가 알아서 사용자 가까운 엣지에 캐싱해 줍니다.

평소 워크플로우
#

개발 중
#

# 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

프로덕션 배포
#

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

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

롤백
#

# 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

비용
#

Curtain Estimator의 활성 사용자는 500명 정도입니다. Tigris에서는 과거 업데이트 약 200MB 저장에 월 $0.02쯤, 업데이트 다운로드로 나가는 월 약 50GB 이그레스에 $1.00쯤 나옵니다. Django 쪽은 원래 API가 돌아가던 Fly.io 인스턴스에 얹혀 있어서 추가 비용이 없고요. 다 합쳐 월 1달러 수준입니다. 비슷한 사용량이면 EAS Update는 연간 $300~500 정도 나올 테니, 본전은 사실상 바로 뽑았습니다.

모니터링과 디버깅
#

특별한 건 없습니다. ViewSet이 매니페스트 요청을 전부 로그로 남깁니다.

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

사실상의 대시보드는 Django admin입니다. 모델만 등록해 두면 조회와 필터링이 다 됩니다.

@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"]

클라이언트 쪽에서는 expo-updates가 지금 무엇을 보고 있는지 알려줍니다.

import * as Updates from 'expo-updates';

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

함정들
#

런타임 버전 불일치
#

가장 흔한 실패가 가장 조용하게 일어납니다. 클라이언트는 자기 런타임 버전과 일치하는 업데이트만 내려받습니다. 설치된 앱이 런타임 1.0.0인데 1.0.1용으로 퍼블리시하면, 아무것도 도착하지 않고 에러도 없습니다. 런타임 버전은 빌드와 맞춰 두고, 올리는 건 네이티브 코드가 바뀔 때만으로 제한하세요.

createdAt 타임스탬프
#

expo-updates 클라이언트는 매니페스트의 createdAt을 내장 번들의 commitTime과 비교해서, createdAt이 더 새로운 업데이트만 적용합니다. ViewSet에서는 데이터베이스 타임스탬프로 덮어씁니다.

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

로컬 개발에서 OTA를 퍼블리시한 뒤에 바이너리를 빌드했다면, 타임스탬프가 더 새로워지도록 OTA를 다시 퍼블리시하세요.

매니페스트 에셋 key
#

매니페스트의 에셋별 key 필드는 expo-updates가 캐싱에 쓰는 값인데, 랜덤 UUID나 아무 문자열이 아니라 결정론적 해시(파일명의 MD5 같은)여야 합니다. 이걸 틀리면 클라이언트가 에셋을 제대로 캐시하거나 가져오지 못해서, 첫 로드만 성공하고 그 뒤로는 업데이트가 조용히 깨집니다. 이 문제를 알려준 독자 Raphael Mutschler에게 감사드립니다. 원인을 찾기 전까지 그의 환경에서는 업데이트가 정확히 한 번만 동작했다고 하네요.

expoClient의 앱 설정
#

Linking, Constants처럼 런타임에 앱 설정을 읽는 기능을 쓰고 있다면, 매니페스트의 extra.expoClient 필드에 앱 설정을 넣어야 합니다. 없으면 처음엔 멀쩡히 실행되다가, 앱을 껐다 다시 열 때 크래시하거나 아예 열리지 않을 수 있습니다. expo-updates가 내장 매니페스트를 OTA 매니페스트로 교체하는데, expoClient가 빠져 있으면 해당 API들이 의존하는 설정에 접근할 길이 없어지기 때문입니다. 이것도 Raphael Mutschler가 잡아준 문제입니다.

에셋 정리
#

오래된 업데이트는 Tigris에 계속 쌓입니다. 지금은 수동으로 정리하고 있습니다.

# 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()

당연히 예약 작업으로 돌려야 할 일인데, 아직 손을 못 댔습니다.

다음에 붙이고 싶은 것들
#

점진적 롤아웃
#

rollout_percentage 필드를 추가하면 일부 사용자에게 먼저 내보낼 수 있습니다.

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

스테이징 업데이트 분리
#

environment 필드를 두면 스테이징 빌드와 프로덕션이 서로 다른 업데이트를 받게 할 수 있습니다.

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()

다운로드 집계
#

작은 모델 하나면 “이 업데이트, 실제로 받은 사람이 있긴 한가?“라는 질문에 제대로 답할 수 있게 됩니다.

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)

마치며
#

시스템 전체가 Django 모델과 뷰 150줄 남짓, 퍼블리시 스크립트 200줄 남짓, 인프라 비용 월 1달러입니다. 시작하기 전에 예상했던 것보다 작게 끝났고, 그 뒤로 Curtain Estimator의 두 플랫폼에서 조용히 제 몫을 하고 있습니다.

위 코드는 그 프로덕션 앱에서 그대로 가져온 것이니, 만드시는 것에 맞게 고쳐 쓰면 됩니다. 독자 Raphael Mutschler가 독립형 구현을 expo-ota-server로 공개해 두었으니, 바로 돌아가는 코드에서 시작하고 싶다면 그쪽도 좋습니다.


전체 사양은 Expo Updates Protocol Specification을 참고하세요.


스크립트 전문
#

평소에 쓰는 건 퍼블리시 스크립트입니다. 동기화 스크립트는 임포트 API를 거치지 않고 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}')
\""