OTA(Over-the-Air) 업데이트를 사용하면 App Store 심사를 기다리지 않고도 JavaScript 변경, 버그 수정, 새로운 기능을 사용자에게 배포할 수 있습니다. Expo의 호스팅 서비스(EAS Update)는 잘 작동하지만, 자체 호스팅에는 그만한 이유가 있습니다: 대규모 비용 관리, 데이터 주권 요구사항, 또는 단순히 인프라를 직접 소유하고 싶은 경우입니다.
이 글에서는 Django REST Framework와 Tigris S3를 사용하여 자체 호스팅 Expo 업데이트 서버를 구축한 방법을 다룹니다. 제 Curtain Estimator 앱에서 iOS와 Android 모두에 OTA 업데이트를 제공하는 아키텍처입니다.
왜 Expo 업데이트를 자체 호스팅해야 할까요?#
비용 관리#
EAS Update 가격은 사용량에 따라 증가합니다. 대규모 사용자 기반에 자주 업데이트를 배포하는 경우, Tigris와 같은 저렴한 S3 호환 스토리지로 자체 호스팅하면 실제로 비용을 절감할 수 있습니다.
데이터 주권#
일부 산업에서는 애플리케이션 자산이 저장되는 위치에 대한 완전한 제어가 필요합니다. 자체 호스팅은 모든 업데이트 번들이 자체 인프라에 유지되도록 보장합니다.
커스텀 비즈니스 로직#
특정 사용자 세그먼트에 업데이트를 배포해야 하나요? 다른 번들로 A/B 테스트를 하고 싶으신가요? 커스텀 서버를 사용하면 전체 업데이트 흐름을 제어할 수 있습니다.
벤더 종속 없음#
업데이트 인프라가 Expo의 서비스 가용성이나 가격 변경에 종속되지 않습니다.
아키텍처 개요#
시스템은 네 가지 주요 구성 요소로 이루어져 있습니다:
- Django 백엔드: 업데이트 매니페스트를 제공하고 메타데이터를 저장
- Tigris S3 스토리지: 실제 번들 및 에셋 파일을 호스팅
- 퍼블리싱 파이프라인: 내보내기, 업로드, 업데이트 등록을 수행하는 스크립트
- 모바일 앱: 서버에서 업데이트를 확인하도록 구성
작동 방식은 다음과 같습니다:
┌─────────────────┐
│ 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
└─────────────────┘구현 세부사항#
Django 모델#
기반이 되는 것은 두 개의 Django 모델입니다: ExpoUpdate와 ExpoUpdateAsset.
ExpoUpdate는 각 업데이트의 메타데이터를 저장합니다:
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_version:
app.json의runtimeVersion과 일치합니다. 이것은 매우 중요합니다—클라이언트는 자신의 런타임 버전과 일치하는 업데이트만 다운로드합니다. - platform: 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 No Content: 업데이트가 없으면 204를 반환합니다. 앱은 내장 번들을 사용합니다.
- Presigned URL: 앱이 Tigris CDN에서 직접 다운로드할 수 있는 시간 제한 URL을 생성합니다—Django를 통해 프록시하는 것보다 훨씬 빠릅니다.
- 헤더: 응답에
expo-protocol-version헤더가 필수입니다.
업데이트 퍼블리싱#
퍼블리싱 워크플로우는 Django 관리 명령과 셸 스크립트 래퍼를 통해 자동화됩니다.
관리 명령 (publish_expo_update.py)은 다음을 처리합니다:
expo export출력 읽기- 모든 에셋의 SHA-256 해시 계산
- 번들과 에셋을 Tigris S3에 병렬 업로드
- 업데이트 및 에셋에 대한 데이터베이스 레코드 생성
- 선택적으로 프로덕션 동기화를 위한 임포트 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주요 기능:
- 프로덕션 환경 변수로 앱을 자동 내보내기
- 속도를 위해 에셋을 병렬 업로드
- 플랫폼별 또는 멀티 플랫폼 업데이트 지원
- API 엔드포인트를 통한 프로덕션 동기화
API를 통한 프로덕션 동기화#
프로덕션 배포의 경우 시스템은 영리한 2단계 프로세스를 사용합니다:
- 로컬 퍼블리시: 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는 AWS S3보다 훨씬 저렴한 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")S3 클라이언트 생성:
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,
)파일 업로드는 CDN 직접 전달을 위해 presigned URL을 사용합니다:
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#
URL은 1시간 후 만료되어 번들에 대한 장기 무단 접근을 방지합니다.
성능 최적화#
데이터베이스 인덱스#
(runtime_version, platform, is_active, -created_at) 복합 인덱스는 수천 개의 업데이트가 있어도 매니페스트 쿼리가 빠르도록 보장합니다:
class Meta:
indexes = [
models.Index(fields=["runtime_version", "platform", "is_active", "-created_at"])
]병렬 업로드#
퍼블리싱 스크립트는 ThreadPoolExecutor를 사용하여 에셋을 동시에 업로드합니다:
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 progress50개의 에셋이 있는 일반적인 업데이트의 경우, 퍼블리시 시간이 2분에서 15초로 단축됩니다.
CDN 배포#
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비용 분석#
약 500명의 활성 사용자가 있는 제 Curtain Estimator 앱의 경우:
Tigris 스토리지:
- 스토리지: ~200 MB (이전 업데이트들) = $0.02/월
- 이그레스: ~50 GB/월 (업데이트 다운로드) = $1.00/월
- 합계: ~$1/월
Django 호스팅 (Fly.io):
- 기존 앱 호스팅에 포함
- 추가 비용: $0
전체 OTA 인프라 비용: ~$1/월
유사한 사용량의 EAS Update 가격과 비교하면 연간 $300-500입니다. 자체 호스팅 접근 방식은 즉시 비용을 절감합니다.
모니터링 및 디버깅#
로깅#
ViewSet은 모든 매니페스트 요청을 로깅합니다:
logger.info(f"Manifest request: platform={platform}, runtime={runtime_version}")Django Admin#
쉬운 검사를 위해 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"]클라이언트 측 디버깅#
앱에서 업데이트 로그를 활성화합니다:
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에서 createdAt을 데이터베이스 타임스탬프로 오버라이드합니다:
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와 S3 호환 스토리지로 자체 호스팅 Expo OTA 업데이트는 생각보다 간단합니다. 전체적으로 필요한 것:
- ~150줄의 Django 모델 및 뷰
- ~200줄의 퍼블리싱 스크립트
- ~$1/월의 인프라 비용
여기의 코드 예제는 제 프로덕션 Curtain Estimator 앱에서 가져온 것입니다. 구축하시는 것에 맞게 활용하세요. 독자 Raphael Mutschler가 바로 사용할 수 있는 독립형 구현을 expo-ota-server에 공개했으니 참고하세요.
전체 사양은 Expo Updates Protocol Specification을 확인하세요.
전체 스크립트#
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}')
\""
