↓ Chuyển đến nội dung chính

React Native Expo OTA Updates

Tự dựng máy chủ cập nhật OTA cho Expo bằng Django và Tigris

Cách tôi tự dựng một máy chủ cập nhật OTA riêng cho Expo bằng Django và Tigris S3, và vì sao chi phí vận hành mỗi tháng chỉ khoảng một đô.

Cập nhật, ngày 13 tháng 9 năm 2026. Sau khi bài này lên, Codemagic đã ra mắt Patch, một máy chủ OTA mã nguồn mở, tự vận hành được (self-hosted), theo mô hình CodePush. Nếu bạn tìm tới đây vì không muốn tự viết máy chủ, thì giờ đã có một lựa chọn thật sự: tôi đã chuyển một ứng dụng từ hệ thống bên dưới sang Patch và viết lại quá trình đó, ngoài ra còn có một ghi chú ngắn ở phần Tổng kết. Minh bạch: Codemagic đã trả tiền để tôi thực hiện đợt di chuyển đó và viết về nó, và họ đề nghị nhắc tới ở đây. Phần còn lại của bài giữ nguyên.

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ụ do chính Expo vận hành, 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 cập nhật 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 mô hình dữ liệu (model), endpoint trả về manifest, quy trình phát hành (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ự vận hành?
#

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 bản cập nhật 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 tài nguyên (asset) của ứng dụng phải nằm trong hạ tầng của chính công ty. Tự nắm máy chủ cũng có nghĩa là tự nắm luồng cập nhật: nếu sản phẩm cần, bạn có thể phát hành dần cho từng nhóm người dùng, hay thử nghiệm A/B với các gói mã (bundle) khác nhau. Và quy trình cập nhật 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 máy chủ Django trả về manifest của bản cập nhật và lưu siêu dữ liệu (metadata); Tigris chứa các tệp gói mã và tài nguyên thật; một script phát hành lo phần kết xuất (export), tải lên và đăng ký bản cập nhật; và cuối cùng là chính ứng dụng di động, được cấu hình trỏ về máy chủ của tôi thay vì máy chủ 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 mô hình dữ liệu
#

Mọi thứ đều xoay quanh hai mô hình ExpoUpdate và ExpoUpdateAsset. Mô hình thứ nhất lưu siêu dữ liệu của từng bản cập nhật:

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 mô hình 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: máy khách chỉ tải những bản cập nhật có cùng phiên bản runtime (runtime version) với mình. iOS và Android được lưu thành hai bản ghi riêng, vì gói mã của hai nền tảng khác nhau. is_active chính là cơ chế khôi phục về phiên bản trước (rollback): chỉ cần tắt bản cập nhật bị lỗi là máy khách 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)

Tài nguyên được định danh bằng mã băm (hash) SHA-256, nên nội dung của chúng không bao giờ đổi và có thể lưu đệm (cache) thoải mái. Một tài nguyên dùng chung cho bao nhiêu bản cập nhật cũng được.

Endpoint manifest
#

/api/expo-updates/manifest/ là nơi ứng dụng và máy chủ thật sự trao đổi với nhau. Endpoint này được xây dựng theo 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, phản hồi (response) 204 nghĩa là “không có bản cập nhật nào”, và ứng dụng cứ thế chạy tiếp với gói mã nhúng sẵn. Thứ hai, URL của tài nguyên trong manifest là URL ký sẵn (presigned URL), nên ứng dụng tải thẳng từ CDN của Tigris chứ không phải truyền mọi thứ qua Django. Thứ ba, header expo-protocol-version trong phản hồi là bắt buộc, vì máy khách sẽ kiểm tra giá trị này.

Phát hành bản cập nhật
#

Phần phát hành là một lệnh quản trị (management command) của Django, bên ngoài bọc thêm một shell script. Lệnh publish_expo_update.py đọc đầu ra của expo export, tính mã băm SHA-256 cho từng tài nguyên, tải song song gói mã và tài nguyên lên Tigris, ghi bản ghi vào cơ sở dữ liệu, và nếu cần thì đặt thêm vào bucket một tệp JSON dùng để nhập (import) dữ liệu, phục vụ việc đồng bộ lên môi trường 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-run

Script này nạp các biến môi trường production trước khi kết xuất, chạy được cho một hoặc cả hai nền tảng, và lo luôn phần đồng bộ 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.

Đồng bộ lên production
#

Tôi không muốn để sẵn thông tin đăng nhập (credential) của production trên laptop, nên việc triển khai lên production được chia làm hai bước. Đầu tiên là phát hành từ môi trường cục bộ (local): tài nguyên được đẩy lên Tigris, kèm một bản chụp siêu dữ liệu dạng JSON. Sau đó gọi một endpoint API trên production, kèm theo đường dẫn S3, để máy chủ tự nhập siêu dữ liệu 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ề máy chủ của bạn
#

Trong app.json, bạn khai báo URL cập nhật và phiên bản runtime:

{
  "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à ở máy chủ lúc nào cũng phải khớp nhau. Mỗi khi thay đổi phần mã native hoặc nâng cấp Expo SDK, hãy tăng phiên bản runtime rồi phát hành bản cập nhật mới cho phiên bản đó.

Lưu trữ trên Tigris
#

Tigris là dịch vụ lưu trữ đối tượng (object storage) tương thích S3, rẻ hơn AWS S3 khá nhiều, lại có sẵn bộ nhớ đệm tại biê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 gói mã 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 phần khởi tạo client S3 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 URL ký sẵn, 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 bản cập nhật ngay cả khi chưa có ai đăng nhập. Endpoint nhập dữ liệu 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ó một khóa bí mật dùng chung (shared secret), và phép so sánh khóa phải chạy trong thời gian không đổi (constant-time) để chặn tấn công định thời (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 tài nguyên thì thiết kế đã lo sẵn: tệp nào cũng được đối chiếu với mã băm SHA-256 của nó, nên tài nguyên nào bị chỉnh sửa sẽ bị loại ngay. Ngoài ra, URL ký sẵn hết hạn sau một giờ, nên không ai dùng liên kết trực tiếp (hotlink) tới gói mã của bạn mãi được.

Giữ cho hệ thống chạy nhanh
#

Nhờ chỉ mục kết hợp (composite index) trên (runtime_version, platform, is_active, -created_at), truy vấn lấy manifest vẫn nhanh dù bản cập nhật có tích lại nhiều đến đâu:

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

Script phát hành tải tài nguyên 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 progress

Với một bản cập nhật điển hình có khoảng 50 tài nguyên, thời gian phát hành 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ự lưu đệm tài nguyên ở các điểm biên (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 dashboard

Khôi phục về phiên bản trước
#

# 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

Chi 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 cập nhật tích lũy từ trước tới giờ tốn chừng $0.02/tháng, còn ~50 GB lưu lượng tải ra (egress) mỗi tháng từ các lượt tải bản cập nhật thì khoảng $1.00. Phần Django chạy ké trên chính máy (instance) Fly.io đang chạy API của tôi, 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 nhật ký (log) mọi yêu cầu lấy manifest:

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

Trên thực tế, Django admin chính là bảng điều khiển. Chỉ cần đăng ký các mô hình 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 máy khách, 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 phiên bản runtime
#

Lỗi hay gặp nhất cũng là lỗi khó phát hiện nhất: máy khách chỉ tải những bản cập nhật khớp với phiên bản runtime của mình. Nếu ứng dụng đã cài đang ở runtime 1.0.0 mà bạn lại phát hành cho 1.0.1, thì sẽ chẳng có bản cập nhật nào về máy, mà cũng chẳng có lỗi nào báo ra. Hãy giữ phiên bản runtime đồng bộ với các bản dựng (build), và chỉ tăng nó khi phần mã native thay đổi.

Dấu thời gian createdAt
#

Thư viện expo-updates phía máy khách so sánh createdAt trong manifest với commitTime của gói mã nhúng sẵn, và chỉ áp dụng bản cập nhật nào có createdAt mới hơn. Trong viewset, tôi ghi đè giá trị này bằng dấu thời gian (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 trên máy cục bộ, nếu bạn dựng lại bản nhị phân của ứng dụng sau khi đã phát hành OTA, hãy phát hành lại bản OTA đó để dấu thời gian của nó mới hơn.

Giá trị key của tài nguyên trong manifest
#

Trường key trên mỗi tài nguyên trong manifest là thứ expo-updates dùng để lưu đệm, và giá trị này phải là một mã băm 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ì máy khách có thể không lưu đệm hoặc không lấy lại được tài nguyên đúng cách, và bản cập nhật 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: bản cập nhật 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 cấu hình ứng dụng (app config) lúc chạy, thì trường extra.expoClient trong manifest phải chứa cấu hình đó. 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ì bị sập (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 tài nguyên
#

Các bản cập nhật 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 tác vụ chạy định kỳ (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
#

(Tháng 9 năm 2026: Patch giờ đã có sẵn cả ba thứ này và hơn thế nữa; xem bài viết về đợt di chuyển.)

Phát hành theo từng đợt
#

Chỉ cần thêm một trường rollout_percentage là có thể phát hành bản cập nhật 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 update

Tách riêng bản cập nhật cho staging
#

Thêm một trường environment thì các bản dựng cho môi trường staging có thể nhận bản cập nhật 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 mô hình 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 mã cho mô hình và view của Django, chừng 200 dòng script phát hành, 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 đang chạy 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 phiên bản độc lập của riêng anh ấy tại expo-ota-server.

Nếu bạn không muốn tự viết máy chủ
#

Từ lúc tôi viết bài này, đã có hai thứ xuất hiện làm được cùng việc mà không cần tới Django. Thứ nhất là expo-ota-server của Raphael ở trên. Thứ hai là Codemagic Patch: mã nguồn mở, chạy trên máy của chính bạn qua một bộ cài Docker Compose, và nói giao thức CodePush thay vì Expo Updates, nên bạn thay expo-updates bằng SDK của nó và thay eas update bằng cmpatch release-react. Nó dùng được kho lưu trữ tương thích S3, nên một bucket Tigris như trong bài này cũng chạy được.

Ngay từ đầu, nó đã có sẵn những thứ tôi liệt kê ở mục “Những thứ tôi muốn làm tiếp” mà chưa bao giờ xây: môi trường triển khai staging và production riêng, phát hành theo tỷ lệ phần trăm, số lượt tải và cài của từng bản, cập nhật bắt buộc, bản vá nhị phân, ký mã (code signing), và khôi phục (rollback) ngay trên thiết bị khi gói mã mới bị sập trước khi ứng dụng kịp báo sẵn sàng. Cái giá phải trả là một bản nhị phân native mới trước khi bất kỳ ai nhận được bản cập nhật qua Patch, vì SDK phải có trong ứng dụng, và một máy chủ phải vận hành thay cho một lệnh quản trị.

Tôi đã chuyển một ứng dụng của chính mình, với dự án native đã commit và quy trình phát hành thật, từ hệ thống trong bài này sang Patch. Bài viết đó kể lại những gì chạy tốt, những gì không, và thật sự thì khó tới mức nào. Codemagic tài trợ cho phần việc đó và có một lượt đọc duyệt về tính chính xác của sự kiện, chứ không phải duyệt biên tập.


Muốn đọc toàn bộ đặc tả, bạn tham khảo tài liệu đặc tả giao thức Expo Updates.


Toàn bộ script
#

Script phát hành là cái tôi dùng hằng ngày. Còn script đồng bộ là phương án cũ hơn: nó bỏ qua API nhập dữ liệu và sao chép thẳng bản ghi cập nhật 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}')
\""