メインコンテンツへスキップ
  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の稼働状況や次の料金改定に振り回されずに済むのも、地味に大きいところです。

全体の構成
#

構成要素は4つです。アップデートのマニフェストを返しメタデータを保存する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
└─────────────────┘

実装
#

2つのモデル
#

すべての土台になるのが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)

ポイントは3つあります。アップデートがないときは204を返し、アプリは組み込みのバンドルをそのまま使い続けます。マニフェスト内のアセットURLはpresigned URLなので、Djangoを経由せずTigrisのCDNから直接ダウンロードされます。そしてレスポンスのexpo-protocol-versionヘッダーは省略できません。クライアントがきちんと確認しています。

アップデートのパブリッシュ
#

パブリッシュはDjangoの管理コマンドと、それを包むシェルスクリプトで行います。管理コマンドのpublish_expo_update.pyは、expo 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

エクスポート前にプロダクションの環境変数を読み込み、プラットフォームは片方でも両方でも指定でき、次に説明するプロダクション同期まで面倒を見てくれます。スクリプト全文は記事の最後に載せています。

プロダクションへの同期
#

プロダクションの認証情報を手元のマシンに置きたくなかったので、本番へのデプロイは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は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など)でなければなりません。ここを間違えると、クライアントがアセットを正しくキャッシュ・取得できなくなり、最初の1回だけ成功したあとアップデートが黙って壊れます。この問題を報告してくれた読者のRaphael Mutschlerさんに感謝します。原因を突き止めるまで、彼の環境ではアップデートがちょうど1回だけ動いていたそうです。

expoClientのアプリ設定
#

LinkingConstantsなど、実行時にアプリ設定を読むものを使っている場合は、マニフェストの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}')
\""