react-native-sop-docs/11-post-release-maintenance/ota-updates.md

16 KiB

OTA Updates with CodePush

CodePush Setup

Installation

# Install CodePush CLI
npm install -g code-push-cli

# Install React Native CodePush
yarn add react-native-code-push

App Center Configuration

# Login to App Center
code-push login

# Create apps
code-push app add SaayamApp-iOS ios react-native
code-push app add SaayamApp-Android android react-native

# Get deployment keys
code-push deployment ls SaayamApp-iOS -k
code-push deployment ls SaayamApp-Android -k

Environment Configuration

.env files:

# .env.staging
CODEPUSH_DEPLOYMENT_KEY_IOS=staging-ios-key
CODEPUSH_DEPLOYMENT_KEY_ANDROID=staging-android-key

# .env.production
CODEPUSH_DEPLOYMENT_KEY_IOS=production-ios-key
CODEPUSH_DEPLOYMENT_KEY_ANDROID=production-android-key

CodePush Integration

App Configuration

App.tsx:

import React, { useEffect, useState } from "react";
import { Alert, Platform } from "react-native";
import codePush from "react-native-code-push";
import Config from "react-native-config";
import { AppNavigator } from "./src/navigation/AppNavigator";
import { LoadingScreen } from "./src/components/LoadingScreen";

const codePushOptions = {
  checkFrequency: codePush.CheckFrequency.ON_APP_RESUME,
  installMode: codePush.InstallMode.ON_NEXT_RESUME,
  mandatoryInstallMode: codePush.InstallMode.IMMEDIATE,
  deploymentKey: Platform.select({
    ios: Config.CODEPUSH_DEPLOYMENT_KEY_IOS,
    android: Config.CODEPUSH_DEPLOYMENT_KEY_ANDROID,
  }),
  updateDialog: {
    appendReleaseDescription: true,
    descriptionPrefix: "\n\nWhat's New:\n",
    mandatoryContinueButtonLabel: "Install Now",
    mandatoryUpdateMessage:
      "A critical update is available and must be installed.",
    optionalIgnoreButtonLabel: "Later",
    optionalInstallButtonLabel: "Install",
    optionalUpdateMessage:
      "An update is available. Would you like to install it?",
    title: "Update Available",
  },
};

const App: React.FC = () => {
  const [syncInProgress, setSyncInProgress] = useState(false);
  const [syncMessage, setSyncMessage] = useState("");

  useEffect(() => {
    codePush.notifyAppReady();
  }, []);

  const codePushStatusDidChange = (syncStatus: codePush.SyncStatus) => {
    switch (syncStatus) {
      case codePush.SyncStatus.CHECKING_FOR_UPDATE:
        setSyncMessage("Checking for updates...");
        break;
      case codePush.SyncStatus.DOWNLOADING_PACKAGE:
        setSyncMessage("Downloading update...");
        setSyncInProgress(true);
        break;
      case codePush.SyncStatus.INSTALLING_UPDATE:
        setSyncMessage("Installing update...");
        break;
      case codePush.SyncStatus.UP_TO_DATE:
        setSyncMessage("App is up to date");
        setSyncInProgress(false);
        break;
      case codePush.SyncStatus.UPDATE_INSTALLED:
        setSyncMessage("Update installed successfully");
        setSyncInProgress(false);
        break;
      default:
        setSyncInProgress(false);
        break;
    }
  };

  const codePushDownloadDidProgress = (progress: codePush.DownloadProgress) => {
    const percentage = Math.round(
      (progress.receivedBytes / progress.totalBytes) * 100
    );
    setSyncMessage(`Downloading update... ${percentage}%`);
  };

  if (syncInProgress) {
    return <LoadingScreen message={syncMessage} />;
  }

  return <AppNavigator />;
};

export default codePush(codePushOptions)(App);

Manual Update Check

// src/utils/updateManager.ts
import codePush from "react-native-code-push";
import { Alert } from "react-native";

export class UpdateManager {
  static async checkForUpdate(): Promise<void> {
    try {
      const update = await codePush.checkForUpdate();

      if (update) {
        Alert.alert(
          "Update Available",
          `Version ${update.appVersion} is available. Would you like to install it now?`,
          [
            { text: "Later", style: "cancel" },
            { text: "Install", onPress: () => this.downloadAndInstall() },
          ]
        );
      } else {
        Alert.alert("No Updates", "Your app is up to date!");
      }
    } catch (error) {
      console.error("Error checking for updates:", error);
    }
  }

  static async downloadAndInstall(): Promise<void> {
    try {
      await codePush.sync({
        installMode: codePush.InstallMode.IMMEDIATE,
        updateDialog: {
          appendReleaseDescription: true,
          descriptionPrefix: "\n\nChanges:\n",
        },
      });
    } catch (error) {
      console.error("Error downloading update:", error);
      Alert.alert(
        "Update Failed",
        "Failed to download update. Please try again later."
      );
    }
  }

  static async getUpdateMetadata(): Promise<codePush.LocalPackage | null> {
    try {
      return await codePush.getUpdateMetadata();
    } catch (error) {
      console.error("Error getting update metadata:", error);
      return null;
    }
  }

  static async rollback(): Promise<void> {
    try {
      await codePush.restartApp();
    } catch (error) {
      console.error("Error rolling back:", error);
    }
  }
}

Deployment Strategies

Staged Rollout

# Deploy to staging first
code-push release-react SaayamApp-iOS ios --deploymentName Staging

# Test staging deployment
# If successful, promote to production with rollout percentage

# Deploy to 10% of production users
code-push release-react SaayamApp-iOS ios --deploymentName Production --rollout 10%

# Monitor metrics and gradually increase rollout
code-push patch SaayamApp-iOS Production --rollout 25%
code-push patch SaayamApp-iOS Production --rollout 50%
code-push patch SaayamApp-iOS Production --rollout 100%

Automated Deployment

scripts/deploy-codepush.js:

#!/usr/bin/env node

const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");

const platform = process.argv[2]; // ios or android
const environment = process.argv[3] || "staging"; // staging or production
const rollout = process.argv[4] || "100"; // rollout percentage

if (!platform || !["ios", "android"].includes(platform)) {
  console.error(
    "Usage: node deploy-codepush.js <ios|android> [staging|production] [rollout%]"
  );
  process.exit(1);
}

const appName = platform === "ios" ? "SaayamApp-iOS" : "SaayamApp-Android";
const deploymentName = environment === "production" ? "Production" : "Staging";

// Get version from package.json
const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"));
const version = packageJson.version;

// Generate release notes from git commits
const releaseNotes = execSync("git log --oneline -10", { encoding: "utf8" })
  .split("\n")
  .filter((line) => line.trim())
  .map((line) => `• ${line.split(" ").slice(1).join(" ")}`)
  .join("\n");

console.log(`Deploying to ${appName} ${deploymentName}...`);
console.log(`Version: ${version}`);
console.log(`Rollout: ${rollout}%`);

try {
  const command = [
    "code-push release-react",
    appName,
    platform,
    `--deploymentName ${deploymentName}`,
    `--description "${releaseNotes}"`,
    `--rollout ${rollout}%`,
    "--mandatory false",
  ].join(" ");

  execSync(command, { stdio: "inherit" });

  console.log(`✅ Successfully deployed to ${appName} ${deploymentName}`);

  // Send notification (Slack, email, etc.)
  notifyDeployment(appName, deploymentName, version, rollout);
} catch (error) {
  console.error("❌ Deployment failed:", error.message);
  process.exit(1);
}

function notifyDeployment(appName, deployment, version, rollout) {
  // Implementation for sending notifications
  console.log(
    `📱 ${appName} ${deployment} v${version} deployed to ${rollout}% of users`
  );
}

Rollback Strategy

# Check deployment history
code-push deployment history SaayamApp-iOS Production

# Rollback to previous version
code-push rollback SaayamApp-iOS Production

# Rollback to specific label
code-push rollback SaayamApp-iOS Production --targetRelease v1.2.3

Update Policies

Update Configuration

// src/config/updatePolicy.ts
import codePush from "react-native-code-push";

export const UpdatePolicy = {
  // Check for updates when app resumes from background
  checkFrequency: codePush.CheckFrequency.ON_APP_RESUME,

  // Install updates on next app restart (non-disruptive)
  installMode: codePush.InstallMode.ON_NEXT_RESTART,

  // For mandatory updates, install immediately
  mandatoryInstallMode: codePush.InstallMode.IMMEDIATE,

  // Minimum background duration before checking (in seconds)
  minimumBackgroundDuration: 60,

  // Update dialog configuration
  updateDialog: {
    appendReleaseDescription: true,
    descriptionPrefix: "\n\nWhat's New:\n",
    mandatoryContinueButtonLabel: "Install Now",
    mandatoryUpdateMessage:
      "A critical update is required to continue using the app.",
    optionalIgnoreButtonLabel: "Not Now",
    optionalInstallButtonLabel: "Install",
    optionalUpdateMessage:
      "An update is available with new features and improvements.",
    title: "Update Available",
  },
};

Conditional Updates

// src/utils/conditionalUpdates.ts
import codePush from "react-native-code-push";
import { Platform } from "react-native";
import DeviceInfo from "react-native-device-info";

export class ConditionalUpdates {
  static async shouldCheckForUpdate(): Promise<boolean> {
    // Don't check for updates in development
    if (__DEV__) return false;

    // Check device conditions
    const batteryLevel = await DeviceInfo.getBatteryLevel();
    const isCharging = await DeviceInfo.isBatteryCharging();
    const connectionType = await DeviceInfo.getConnectionType();

    // Only update if:
    // - Battery > 20% OR device is charging
    // - Connected to WiFi (for large updates)
    const batteryOk = batteryLevel > 0.2 || isCharging;
    const connectionOk = connectionType === "wifi";

    return batteryOk && connectionOk;
  }

  static async checkForUpdateWithConditions(): Promise<void> {
    const shouldCheck = await this.shouldCheckForUpdate();

    if (!shouldCheck) {
      console.log("Skipping update check due to device conditions");
      return;
    }

    try {
      const update = await codePush.checkForUpdate();

      if (update) {
        // Check update size
        const updateSizeMB = (update.packageSize || 0) / (1024 * 1024);

        if (updateSizeMB > 10) {
          // Large update - require WiFi and user confirmation
          this.promptForLargeUpdate(update, updateSizeMB);
        } else {
          // Small update - download automatically
          this.downloadUpdate(update);
        }
      }
    } catch (error) {
      console.error("Error checking for conditional update:", error);
    }
  }

  private static promptForLargeUpdate(
    update: codePush.RemotePackage,
    sizeMB: number
  ) {
    // Show custom dialog for large updates
    // Implementation depends on your UI framework
  }

  private static async downloadUpdate(update: codePush.RemotePackage) {
    try {
      await update.download();
      await update.install(codePush.InstallMode.ON_NEXT_RESTART);
    } catch (error) {
      console.error("Error downloading update:", error);
    }
  }
}

Monitoring & Analytics

Update Analytics

// src/utils/updateAnalytics.ts
import AnalyticsService from "@config/analytics.config";
import codePush from "react-native-code-push";

export class UpdateAnalytics {
  static trackUpdateCheck() {
    AnalyticsService.logEvent("codepush_check_for_update");
  }

  static trackUpdateAvailable(update: codePush.RemotePackage) {
    AnalyticsService.logEvent("codepush_update_available", {
      app_version: update.appVersion,
      package_size: update.packageSize,
      is_mandatory: update.isMandatory,
      deployment_key: update.deploymentKey,
    });
  }

  static trackUpdateDownloadStart(update: codePush.RemotePackage) {
    AnalyticsService.logEvent("codepush_download_start", {
      app_version: update.appVersion,
      package_size: update.packageSize,
    });
  }

  static trackUpdateDownloadComplete(update: codePush.LocalPackage) {
    AnalyticsService.logEvent("codepush_download_complete", {
      app_version: update.appVersion,
      package_size: update.packageSize,
    });
  }

  static trackUpdateInstalled(update: codePush.LocalPackage) {
    AnalyticsService.logEvent("codepush_update_installed", {
      app_version: update.appVersion,
      install_mode: update.installMode,
    });
  }

  static trackUpdateFailed(error: Error, context?: string) {
    AnalyticsService.logEvent("codepush_update_failed", {
      error_message: error.message,
      context: context || "unknown",
    });
  }

  static trackRollback(reason: string) {
    AnalyticsService.logEvent("codepush_rollback", {
      reason,
    });
  }
}

Health Monitoring

// src/utils/updateHealthMonitor.ts
import codePush from "react-native-code-push";
import CrashlyticsService from "@config/firebase.config";

export class UpdateHealthMonitor {
  private static readonly CRASH_THRESHOLD = 3;
  private static crashCount = 0;

  static async monitorUpdateHealth() {
    try {
      const updateMetadata = await codePush.getUpdateMetadata();

      if (updateMetadata && updateMetadata.isFirstRun) {
        // This is the first run after an update
        this.trackFirstRunAfterUpdate(updateMetadata);

        // Start monitoring for crashes
        this.startCrashMonitoring();
      }
    } catch (error) {
      console.error("Error monitoring update health:", error);
    }
  }

  private static trackFirstRunAfterUpdate(update: codePush.LocalPackage) {
    CrashlyticsService.log(
      `First run after CodePush update: ${update.appVersion}`
    );

    // Set custom attributes for crash reporting
    CrashlyticsService.setUserAttributes({
      codepush_version: update.appVersion || "unknown",
      codepush_label: update.label || "unknown",
      is_codepush_update: "true",
    });
  }

  private static startCrashMonitoring() {
    // Monitor app crashes for the next 24 hours
    const monitoringDuration = 24 * 60 * 60 * 1000; // 24 hours

    setTimeout(() => {
      this.evaluateUpdateStability();
    }, monitoringDuration);
  }

  private static async evaluateUpdateStability() {
    if (this.crashCount >= this.CRASH_THRESHOLD) {
      console.warn(
        `Update appears unstable (${this.crashCount} crashes). Consider rollback.`
      );

      // Optionally trigger automatic rollback
      // await this.performAutomaticRollback();
    } else {
      console.log("Update appears stable");
      CrashlyticsService.log("CodePush update validated as stable");
    }
  }

  private static async performAutomaticRollback() {
    try {
      await codePush.restartApp();
      CrashlyticsService.log("Automatic rollback performed due to instability");
    } catch (error) {
      console.error("Failed to perform automatic rollback:", error);
    }
  }

  static reportCrash() {
    this.crashCount++;
    CrashlyticsService.log(`Crash reported. Count: ${this.crashCount}`);
  }
}

Best Practices

Update Guidelines

  1. Test Thoroughly

    • Test updates on staging environment
    • Verify on multiple devices and OS versions
    • Test offline scenarios
  2. Gradual Rollout

    • Start with small percentage (5-10%)
    • Monitor crash rates and user feedback
    • Gradually increase rollout
  3. Rollback Strategy

    • Have rollback plan ready
    • Monitor key metrics after deployment
    • Set up automated alerts for issues
  4. Update Frequency

    • Don't update too frequently (user fatigue)
    • Bundle related changes together
    • Consider user timezone for deployments
  5. Communication

    • Provide clear release notes
    • Notify users of important changes
    • Use appropriate update messaging