Với Curtain Estimator, ứng dụng di động của tôi, các bản sửa lỗi được đẩy thẳng qua mạng (over the air): phần mã JavaScript mới về tận điện thoại người dùng mà không phải chờ App Store duyệt. Thật ra EAS Update, dịch vụ hosted của Expo, sẵn sàng lo việc này giúp bạn. Nhưng tôi vẫn tự làm một bản riêng bằng Django REST Framework và Tigris S3, và từ đó tới giờ, mọi bản update cho cả iOS lẫn Android đều đi qua hệ thống này.
Trong bài này, tôi sẽ đi qua toàn bộ hệ thống: các model, endpoint manifest, pipeline publish, và cả những cái bẫy mà phải tự vận hành thì bạn mới gặp.
Vì sao lại tự host?#
Với tôi thì lý do chính là tiền. EAS Update tính phí theo mức sử dụng, nên khi bạn đẩy update thường xuyên cho một lượng người dùng ngày càng đông, chi phí sẽ cộng dồn lên. Trong khi đó, dịch vụ lưu trữ tương thích S3 như Tigris thì gần như miễn phí. Cũng có vài lý do khác, biết đâu còn quan trọng hơn với bạn. Một số ngành bắt buộc mọi asset của ứng dụng phải nằm trong hạ tầng của chính công ty. Tự nắm server cũng có nghĩa là tự nắm luồng update: nếu sản phẩm cần, bạn có thể rollout cho từng nhóm người dùng, hay A/B test các bundle khác nhau. Và pipeline update của bạn không còn phụ thuộc vào chuyện dịch vụ của Expo có ổn định hay không, hay lần tới họ sẽ đổi bảng giá ra sao.
Các thành phần kết nối với nhau thế nào#
Hệ thống gồm bốn thành phần: một backend Django trả về manifest update và lưu metadata; Tigris chứa các tệp bundle và asset thật; một script publish lo phần export, tải lên và đăng ký update; và cuối cùng là chính ứng dụng di động, được cấu hình trỏ về server của tôi thay vì server của 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
└─────────────────┘Bắt tay vào làm#
Hai model#
Mọi thứ đều xoay quanh hai model ExpoUpdate và ExpoUpdateAsset. Model thứ nhất lưu metadata của từng bản 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"])
]Trong model này có vài chỗ tôi cố ý thiết kế như vậy. runtime_version tương ứng với runtimeVersion trong app.json, và trường này gánh khá nhiều việc: client chỉ tải những update có cùng runtime version với mình. iOS và Android được lưu thành hai row riêng, vì bundle của hai nền tảng khác nhau. is_active chính là cơ chế rollback: chỉ cần tắt bản update bị lỗi là client quay về bản trước đó. Còn manifest_data lưu nguyên manifest theo giao thức Expo Updates v1 dưới dạng JSON, nên về sau muốn trả manifest ra thì chỉ cần tra một lần là xong.
ExpoUpdateAsset thì theo dõi từng tệp riêng lẻ:
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)Asset được định danh bằng hash SHA-256, nên nội dung của chúng không bao giờ đổi và có thể cache thoải mái. Một asset dùng chung cho bao nhiêu bản update cũng được.
Endpoint manifest#
/api/expo-updates/manifest/ là nơi ứng dụng và server thật sự trao đổi với nhau. Endpoint này implement giao thức 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)Ở đây có ba chi tiết cần để ý. Thứ nhất, response 204 nghĩa là “không có update nào”, và ứng dụng cứ thế chạy tiếp với bundle nhúng sẵn. Thứ hai, URL của asset trong manifest là presigned URL, nên ứng dụng tải thẳng từ CDN của Tigris chứ không phải stream mọi thứ qua Django. Thứ ba, header expo-protocol-version trong response là bắt buộc, vì client sẽ kiểm tra nó.
Publish update#
Phần publish là một management command của Django, bên ngoài bọc thêm một shell script. Command publish_expo_update.py đọc đầu ra của expo export, tính hash SHA-256 cho từng asset, tải song song bundle và asset lên Tigris, ghi record vào cơ sở dữ liệu, và nếu cần thì đặt thêm một tệp JSON import vào bucket để sync lên production. Luồng chính trông như sau:
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(...)Còn thứ tôi thật sự gõ hằng ngày là script bọc ngoài, 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-runScript này load các biến môi trường production trước khi export, chạy được cho một hoặc cả hai nền tảng, và lo luôn phần sync lên production mà tôi sẽ nói ngay bên dưới. Bản đầy đủ của script nằm ở cuối bài.
Sync lên production#
Tôi không muốn để sẵn credential production trên laptop, nên việc deploy lên production được chia làm hai bước. Đầu tiên là publish ở local: asset được đẩy lên Tigris, kèm một bản snapshot metadata dạng JSON. Sau đó gọi một endpoint API trên production, kèm theo đường dẫn S3, để server tự import metadata về:
@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)Trỏ ứng dụng về server của bạn#
Trong app.json, bạn đặt URL update và runtime version:
{
"expo": {
"runtimeVersion": "1.0.0",
"updates": {
"url": "https://your-server.com/api/expo-updates/manifest/"
}
}
}Chỗ này phải thật chặt chẽ: runtimeVersion ở ứng dụng và ở server lúc nào cũng phải khớp nhau. Mỗi khi thay đổi native code hoặc nâng cấp Expo SDK, hãy tăng runtime version rồi publish update mới cho version đó.
Lưu trữ trên Tigris#
Tigris là dịch vụ object storage tương thích S3, rẻ hơn AWS S3 khá nhiều, lại có sẵn edge cache trên toàn cầu. Điểm này rất đáng giá khi người dùng phải tải về những bundle nặng vài MB. Cấu hình phía Django như sau:
# 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")Còn tạo client thì chỉ là boto3 như bình thường:
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,
)Mọi lượt tải đều đi qua presigned URL, nên ứng dụng lấy tệp thẳng từ CDN:
presigned_url = s3_client.generate_presigned_url(
"get_object",
Params={"Bucket": bucket_name, "Key": asset.file_path},
ExpiresIn=3600, # 1 hour
)Bảo mật#
Endpoint manifest được cố ý để không cần xác thực, vì ứng dụng phải nhận được update ngay cả khi chưa có ai đăng nhập. Endpoint import thì khác hẳn. Endpoint này ghi được vào cơ sở dữ liệu production, nên bắt buộc phải có shared secret, và secret phải được so sánh theo kiểu constant-time để chặn timing attack:
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)Tính toàn vẹn của asset thì thiết kế đã lo sẵn: tệp nào cũng được đối chiếu với hash SHA-256 của nó, nên asset nào bị chỉnh sửa sẽ bị loại ngay. Ngoài ra, presigned URL hết hạn sau một giờ, nên không ai hotlink bundle của bạn mãi được.
Giữ cho hệ thống chạy nhanh#
Nhờ composite index trên (runtime_version, platform, is_active, -created_at), query lấy manifest vẫn nhanh dù update có tích lại nhiều đến đâu:
class Meta:
indexes = [
models.Index(fields=["runtime_version", "platform", "is_active", "-created_at"])
]Script publish tải asset lên song song:
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 progressVới một bản update điển hình có khoảng 50 asset, thời gian publish giảm từ chừng 2 phút xuống còn 15 giây. Khâu phân phối thì không phải làm gì cả: Tigris tự cache asset ở các edge location gần người dùng của bạn.
Quy trình làm việc hằng ngày#
Trong lúc phát triển#
# 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Đưa lên production#
# 1. Publish to production
yarn publish-update:prod:ios
# 2. Monitor
# Check Django admin for update records
# Verify assets in Tigris dashboardRollback#
# 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 updateChi phí thực tế#
Curtain Estimator hiện có khoảng 500 người dùng hoạt động. Trên Tigris, ~200 MB các bản update tích lũy từ trước tới giờ tốn chừng $0.02/tháng, còn ~50 GB egress mỗi tháng từ lượt tải update thì khoảng $1.00. Phần Django chạy ké trên instance Fly.io mà API của tôi vốn đang chạy, nên không phát sinh thêm đồng nào. Tính tròn thì tổng cộng là một đô mỗi tháng. Với mức sử dụng tương tự, EAS Update sẽ rơi vào khoảng $300–500/năm, nên có thể nói hệ thống này hoàn vốn gần như ngay lập tức.
Giám sát và gỡ lỗi#
Phần này chẳng có gì cầu kỳ. Viewset ghi log mọi request lấy manifest:
logger.info(f"Manifest request: platform={platform}, runtime={runtime_version}")Trên thực tế, Django admin chính là dashboard. Chỉ cần đăng ký các model là bạn xem và lọc được mọi thứ:
@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"]Còn ở phía client, expo-updates sẽ cho bạn biết nó đang thấy gì:
import * as Updates from 'expo-updates';
Updates.checkForUpdateAsync().then(update => {
console.log('Update available:', update.isAvailable);
console.log('Manifest:', update.manifest);
});Những cái bẫy#
Lệch runtime version#
Lỗi hay gặp nhất cũng là lỗi khó phát hiện nhất: client chỉ tải những update khớp với runtime version của nó. Nếu ứng dụng đã cài đang ở runtime 1.0.0 mà bạn lại publish cho 1.0.1, thì sẽ chẳng có update nào về máy, mà cũng chẳng có lỗi nào báo ra. Hãy giữ runtime version đồng bộ với các bản build, và chỉ tăng nó khi native code thay đổi.
Timestamp createdAt#
Client expo-updates so sánh createdAt trong manifest với commitTime của bundle nhúng sẵn, và chỉ áp dụng update nào có createdAt mới hơn. Trong viewset, tôi ghi đè giá trị này bằng timestamp lấy từ cơ sở dữ liệu:
manifest_data["createdAt"] = update.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ")Khi phát triển ở local, nếu bạn build binary sau khi đã publish OTA, hãy publish lại bản OTA đó để timestamp của nó mới hơn.
Giá trị key của asset trong manifest#
Trường key trên mỗi asset trong manifest là thứ expo-updates dùng để cache, và giá trị này phải là một hash tất định (MD5 của tên tệp là được), chứ không phải UUID ngẫu nhiên hay một chuỗi tùy ý. Làm sai chỗ này thì client có thể không cache hoặc không lấy lại được asset đúng cách, và update sẽ âm thầm hỏng sau lần tải thành công đầu tiên. Cảm ơn bạn đọc Raphael Mutschler đã chỉ ra lỗi này: update bên anh ấy chỉ chạy được đúng một lần, rồi anh mới tìm ra nguyên nhân.
Cấu hình ứng dụng trong expoClient#
Nếu ứng dụng của bạn dùng Linking, Constants, hay bất cứ thứ gì đọc app config lúc runtime, thì trường extra.expoClient trong manifest phải chứa app config đó. Thiếu trường này, lúc đầu ứng dụng có thể vẫn mở lên bình thường, nhưng sau khi bị đóng thì crash hoặc không chịu mở lại nữa. Lý do là expo-updates thay manifest nhúng sẵn bằng manifest OTA, và nếu thiếu expoClient thì các API kia mất luôn phần cấu hình mà chúng dựa vào. Lỗi này cũng do Raphael Mutschler phát hiện.
Dọn dẹp asset#
Các bản update cũ cứ thế chất đống trên Tigris. Hiện tại tôi vẫn dọn bằng tay:
# 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()Rõ ràng đoạn này nên được đưa vào một scheduled task. Chỉ là tôi chưa có thời gian làm.
Những thứ tôi muốn làm tiếp#
Rollout theo từng đợt#
Chỉ cần thêm một trường rollout_percentage là có thể phát hành update cho một phần người dùng trước:
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 updateTách riêng update cho staging#
Thêm một trường environment thì các bản build staging có thể nhận update khác với 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()Thống kê lượt tải#
Và một model nhỏ nữa sẽ trả lời đàng hoàng câu hỏi “rốt cuộc đã có ai nhận được bản này chưa?”:
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)Tổng kết#
Toàn bộ hệ thống chỉ gồm khoảng 150 dòng model và view Django, chừng 200 dòng script publish, và một đô mỗi tháng tiền hạ tầng. Ít hơn nhiều so với tôi nghĩ lúc bắt đầu, và từ đó tới nay nó vẫn lặng lẽ làm tốt việc của mình cho Curtain Estimator trên cả hai nền tảng.
Phần mã ở trên được lấy nguyên từ chính ứng dụng production đó, nên bạn cứ điều chỉnh cho hợp với dự án của mình. Còn nếu muốn bắt đầu từ một thứ đã chạy được sẵn, bạn đọc Raphael Mutschler cũng đã công bố một bản implement độc lập tại expo-ota-server.
Muốn xem spec đầy đủ, bạn tham khảo đặc tả giao thức Expo Updates.
Toàn bộ script#
Script publish là cái tôi dùng hằng ngày. Còn script sync là phương án cũ hơn: nó bỏ qua API import và copy thẳng record update vào cơ sở dữ liệu production qua 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}')
\""
