diff --git a/.github/workflows/pull-request-commit.yml b/.github/workflows/pull-request-commit.yml index afa3d4f46c..a06556ea15 100644 --- a/.github/workflows/pull-request-commit.yml +++ b/.github/workflows/pull-request-commit.yml @@ -275,6 +275,10 @@ jobs: permissions: id-token: write contents: read + outputs: + release-version: ${{ steps.env.outputs.release-version }} + ios-build-number: ${{ steps.build-info.outputs.BSKY_IOS_BUILD_NUMBER }} + android-build-number: ${{ steps.build-info.outputs.BSKY_ANDROID_VERSION_CODE }} steps: - name: ⬇️ Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -342,11 +346,18 @@ jobs: app-id: ${{ vars.SYNC_INTERNAL_APP_ID }} private-key: ${{ secrets.SYNC_INTERNAL_PK }} + - name: 🔢 Get native build numbers + id: build-info + run: bash scripts/setGitHubOutput.sh + - name: 🚀 Publish OTA to denis (S3) run: pnpm use-build-number bash scripts/denisPublish.sh env: RUNTIME_VERSION: '' CHANNEL_NAME: pull-request-${{ github.event.pull_request.number }} + # Pin the publish to the same values exposed in the install link. + BSKY_IOS_BUILD_NUMBER: ${{ steps.build-info.outputs.BSKY_IOS_BUILD_NUMBER }} + BSKY_ANDROID_VERSION_CODE: ${{ steps.build-info.outputs.BSKY_ANDROID_VERSION_CODE }} comment-pr-ota: name: Comment PR OTA install link @@ -362,6 +373,6 @@ jobs: message: | The OTA deployment for this PR was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser: - QR code for the PR OTA deployment + QR code for the PR OTA deployment - `bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}` + `bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}&releaseVersion=${{ needs.publish-pr-ota.outputs.release-version }}&iosBuildNumber=${{ needs.publish-pr-ota.outputs.ios-build-number }}&androidBuildNumber=${{ needs.publish-pr-ota.outputs.android-build-number }}` diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index a4af3a3233..9f8f3cc426 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -707,7 +707,7 @@ "count": 1 }, "typescript/no-misused-promises": { - "count": 4 + "count": 3 } }, "src/lib/hooks/usePermissions.ts": { diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index eaa6482830..61bc201467 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -84,10 +84,18 @@ export function useIntentHandler() { } case 'apply-ota': { const channel = params.get('channel') + const releaseVersion = params.get('releaseVersion') + const buildNumber = params.get( + IS_IOS ? 'iosBuildNumber' : 'androidBuildNumber', + ) + const appVersion = + releaseVersion && buildNumber + ? `${releaseVersion}.${buildNumber}` + : null if (!channel) { Alert.alert('Error', 'No channel provided to look for.') } else { - tryApplyUpdate(channel) + tryApplyUpdate(channel, appVersion) } return } diff --git a/src/lib/hooks/useOTAUpdates.test.ts b/src/lib/hooks/useOTAUpdates.test.ts new file mode 100644 index 0000000000..d174182a33 --- /dev/null +++ b/src/lib/hooks/useOTAUpdates.test.ts @@ -0,0 +1,215 @@ +import {Alert} from 'react-native' +import { + checkForUpdateAsync, + fetchUpdateAsync, + reloadAsync, + setExtraParamAsync, + useUpdates, +} from 'expo-updates' +import {act, renderHook, waitFor} from '@testing-library/react-native' + +import {logger} from '#/logger' +import {device} from '#/storage' +import { + useApplyPullRequestOTAUpdate, + useOTAUpdateRecovery, +} from './useOTAUpdates' + +jest.mock('expo-updates', () => ({ + checkForUpdateAsync: jest.fn(), + fetchUpdateAsync: jest.fn(), + isEnabled: true, + reloadAsync: jest.fn(), + setExtraParamAsync: jest.fn(), + UpdateCheckResultNotAvailableReason: { + UPDATE_PREVIOUSLY_FAILED: 'updatePreviouslyFailed', + }, + useUpdates: jest.fn(), +})) + +jest.mock('#/logger', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + }, +})) + +jest.mock('#/storage', () => ({ + device: { + get: jest.fn(), + remove: jest.fn(), + set: jest.fn(), + }, +})) + +const currentUpdate = { + channel: 'testflight', + emergencyLaunchReason: null, + isEmbeddedLaunch: false, + isEmergencyLaunch: false, + updateId: 'current-update', +} + +beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useUpdates).mockReturnValue({ + currentlyRunning: currentUpdate, + } as ReturnType) + jest.mocked(setExtraParamAsync).mockResolvedValue(undefined) + jest.mocked(reloadAsync).mockResolvedValue(undefined) + jest.spyOn(Alert, 'alert').mockImplementation(() => {}) +}) + +describe('useApplyPullRequestOTAUpdate', () => { + it('warns before applying an OTA built for a different app version', async () => { + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: true, + } as Awaited>) + jest.mocked(fetchUpdateAsync).mockResolvedValue({ + isNew: true, + isRollBackToEmbedded: false, + manifest: {id: 'mismatched-update'}, + } as Awaited>) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0')) + + expect(checkForUpdateAsync).not.toHaveBeenCalled() + expect(Alert.alert).toHaveBeenCalledWith( + 'App Version Mismatch', + expect.stringContaining('Applying it anyway may cause'), + expect.arrayContaining([expect.objectContaining({text: 'Apply Anyway'})]), + ) + + const buttons = jest.mocked(Alert.alert).mock.calls[0][2] + act(() => buttons?.[1].onPress?.()) + + await waitFor(() => expect(reloadAsync).toHaveBeenCalled()) + expect(device.set).toHaveBeenCalledWith(['pendingOTAUpdate'], { + attemptedAt: expect.any(Number), + channel: 'pull-request-123', + updateId: 'mismatched-update', + }) + }) + + it('informs the user when checking for an OTA fails', async () => { + jest.mocked(checkForUpdateAsync).mockRejectedValue(new Error('offline')) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-123')) + + expect(Alert.alert).toHaveBeenCalledWith( + 'Update Check Failed', + expect.stringContaining('Error: offline'), + ) + expect(result.current.pending).toBe(false) + }) + + it('informs the user when downloading an OTA fails', async () => { + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: true, + } as Awaited>) + jest + .mocked(fetchUpdateAsync) + .mockRejectedValue(new Error('download failed')) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-123')) + const buttons = jest.mocked(Alert.alert).mock.calls[0][2] + + act(() => buttons?.[1].onPress?.()) + + await waitFor(() => + expect(Alert.alert).toHaveBeenLastCalledWith( + 'Update Failed', + expect.stringContaining('Error: download failed'), + ), + ) + expect(device.set).not.toHaveBeenCalled() + expect(result.current.pending).toBe(false) + }) + + it('clears the recovery marker and informs the user when reloading fails', async () => { + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: true, + } as Awaited>) + jest.mocked(fetchUpdateAsync).mockResolvedValue({ + isNew: true, + isRollBackToEmbedded: false, + manifest: {id: 'new-update'}, + } as Awaited>) + jest.mocked(reloadAsync).mockRejectedValue(new Error('reload failed')) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-123')) + const buttons = jest.mocked(Alert.alert).mock.calls[0][2] + + act(() => buttons?.[1].onPress?.()) + + await waitFor(() => + expect(Alert.alert).toHaveBeenLastCalledWith( + 'Update Failed', + expect.stringContaining('Error: reload failed'), + ), + ) + expect(device.set).toHaveBeenCalledWith(['pendingOTAUpdate'], { + attemptedAt: expect.any(Number), + channel: 'pull-request-123', + updateId: 'new-update', + }) + expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate']) + expect(result.current.pending).toBe(false) + }) +}) + +describe('useOTAUpdateRecovery', () => { + it('informs the user when Expo fell back from the attempted OTA', async () => { + jest.mocked(device.get).mockReturnValue({ + attemptedAt: Date.now(), + channel: 'pull-request-123', + updateId: 'failed-update', + }) + + renderHook(() => useOTAUpdateRecovery()) + + await waitFor(() => + expect(Alert.alert).toHaveBeenCalledWith( + 'Update Failed', + expect.stringContaining('PR #123 deployment could not start'), + ), + ) + expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate']) + expect(logger.error).toHaveBeenCalledWith( + 'Custom OTA Update Failed to Launch', + expect.objectContaining({attemptedUpdateId: 'failed-update'}), + ) + }) + + it('recognizes a launched OTA when the update ID casing differs', () => { + jest.mocked(device.get).mockReturnValue({ + attemptedAt: Date.now(), + channel: 'pull-request-123', + updateId: currentUpdate.updateId.toUpperCase(), + }) + + renderHook(() => useOTAUpdateRecovery()) + + expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate']) + expect(Alert.alert).not.toHaveBeenCalled() + expect(logger.error).not.toHaveBeenCalled() + }) + + it('silently clears a stale marker from an older OTA bundle', () => { + jest.mocked(device.get).mockReturnValue({ + attemptedAt: Date.now() - 10 * 60e3, + channel: 'pull-request-123', + updateId: 'older-update', + }) + + renderHook(() => useOTAUpdateRecovery()) + + expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate']) + expect(Alert.alert).not.toHaveBeenCalled() + expect(logger.error).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts index 069367dd33..a7e043aeb2 100644 --- a/src/lib/hooks/useOTAUpdates.ts +++ b/src/lib/hooks/useOTAUpdates.ts @@ -7,14 +7,17 @@ import { isEnabled, reloadAsync, setExtraParamAsync, + UpdateCheckResultNotAvailableReason, useUpdates, } from 'expo-updates' import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' -import {IS_IOS, IS_TESTFLIGHT} from '#/env' +import {APP_VERSION, IS_IOS, IS_TESTFLIGHT} from '#/env' +import {device} from '#/storage' const MINIMUM_MINIMIZE_TIME = 15 * 60e3 +const OTA_RECOVERY_WINDOW = 5 * 60e3 /** * The channel this native build is expected to receive updates from. Anything @@ -28,6 +31,11 @@ const DEFAULT_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production' */ const STANDARD_CHANNELS = ['production', 'testflight', 'development'] +function getDeploymentName(channel: string) { + const pullRequestNumber = channel.match(/^pull-request-(\d+)$/)?.[1] + return pullRequestNumber ? `PR #${pullRequestNumber}` : channel +} + async function setExtraParams() { await setExtraParamAsync( IS_IOS ? 'ios-build-number' : 'android-build-number', @@ -89,36 +97,116 @@ export function useApplyPullRequestOTAUpdate() { currentChannel && !STANDARD_CHANNELS.includes(currentChannel), ) - const tryApplyUpdate = async (channel: string) => { - setPending(true) - await setExtraParamsPullRequest(channel) - const res = await checkForUpdateAsync() - if (res.isAvailable) { + const tryApplyUpdate = async ( + channel: string, + declaredAppVersion?: string | null, + ) => { + const deploymentName = getDeploymentName(channel) + + const checkForDeployment = async () => { + await setExtraParamsPullRequest(channel) + const res = await checkForUpdateAsync() + if (!res.isAvailable) { + if ( + res.reason === + UpdateCheckResultNotAvailableReason.UPDATE_PREVIOUSLY_FAILED + ) { + Alert.alert( + 'Deployment Blocked', + `The ${deploymentName} deployment previously failed to start on this device, so the app will not try to apply it again.`, + ) + } else if (currentChannel !== channel) { + Alert.alert( + 'No Deployment Available', + `No new deployments of ${channel} are currently available for your current native build.`, + ) + } + } + return res.isAvailable + } + + const applyUpdate = () => { + setPending(true) + void (async () => { + try { + if (!(await checkForDeployment())) return + const fetchedUpdate = await fetchUpdateAsync() + if (!fetchedUpdate.isNew) { + throw new Error('Expo did not download a new update.') + } + + device.set(['pendingOTAUpdate'], { + attemptedAt: Date.now(), + channel, + updateId: fetchedUpdate.manifest.id, + }) + try { + await reloadAsync() + } catch (e) { + device.remove(['pendingOTAUpdate']) + throw e + } + } catch (e: unknown) { + const error = String(e) + logger.error('Internal OTA Update Error', {error}) + Alert.alert( + 'Update Failed', + `Could not apply the ${deploymentName} deployment: ${error}`, + ) + } finally { + setPending(false) + } + })() + } + + if (declaredAppVersion && declaredAppVersion !== APP_VERSION) { Alert.alert( - 'Deployment Available', - `A deployment of ${channel} is availalble. Applying this deployment may result in a bricked installation, in which case you will need to reinstall the app and may lose local data. Are you sure you want to proceed?`, + 'App Version Mismatch', + `This OTA update was built for a different version of the app.\n\nCurrent app version: ${APP_VERSION}\nOTA app version: ${declaredAppVersion}\n\nApplying it anyway may cause the app to stop working and require a reinstall.`, [ { - text: 'No', + text: 'Cancel', style: 'cancel', }, { - text: 'Relaunch', - style: 'default', - onPress: async () => { - await fetchUpdateAsync() - await reloadAsync() - }, + text: 'Apply Anyway', + style: 'destructive', + onPress: applyUpdate, }, ], ) - } else { - Alert.alert( - 'No Deployment Available', - `No new deployments of ${channel} are currently available for your current native build.`, - ) + return + } + + setPending(true) + try { + if (!(await checkForDeployment())) return + + Alert.alert( + `Apply update from ${deploymentName}?`, + 'The app will relaunch after the update is applied.', + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Apply', + style: 'default', + onPress: applyUpdate, + }, + ], + ) + } catch (e: unknown) { + const error = String(e) + logger.error('Internal OTA Update Error', {error}) + Alert.alert( + 'Update Check Failed', + `Could not check the ${deploymentName} deployment: ${error}`, + ) + } finally { + setPending(false) } - setPending(false) } /** @@ -161,6 +249,50 @@ export function useApplyPullRequestOTAUpdate() { } } +/** + * Reports when expo-updates recovered from a custom OTA that failed to launch. + * The attempted update ID is persisted before reload so the previous bundle can + * distinguish a successful relaunch from an automatic fallback. + */ +export function useOTAUpdateRecovery() { + const {currentlyRunning} = useUpdates() + + useEffect(() => { + const pendingUpdate = device.get(['pendingOTAUpdate']) + if (!pendingUpdate || !currentlyRunning) return + + device.remove(['pendingOTAUpdate']) + if ( + pendingUpdate.updateId.toLowerCase() === + currentlyRunning.updateId?.toLowerCase() + ) { + return + } + + // A fallback relaunch is immediate. A stale marker can be left by a + // successful runtime-compatible bundle that predates this hook. + if ( + typeof pendingUpdate.attemptedAt !== 'number' || + Date.now() - pendingUpdate.attemptedAt >= OTA_RECOVERY_WINDOW + ) { + return + } + + const deploymentName = getDeploymentName(pendingUpdate.channel) + logger.error('Custom OTA Update Failed to Launch', { + channel: pendingUpdate.channel, + attemptedUpdateId: pendingUpdate.updateId, + currentUpdateId: currentlyRunning.updateId, + isEmergencyLaunch: currentlyRunning.isEmergencyLaunch, + emergencyLaunchReason: currentlyRunning.emergencyLaunchReason, + }) + Alert.alert( + 'Update Failed', + `The ${deploymentName} deployment could not start. The app recovered by loading a working version instead.`, + ) + }, [currentlyRunning]) +} + export function useOTAUpdates() { const shouldReceiveUpdates = isEnabled && !__DEV__ diff --git a/src/lib/hooks/useOTAUpdates.web.ts b/src/lib/hooks/useOTAUpdates.web.ts index 6938a5b003..970deebf1b 100644 --- a/src/lib/hooks/useOTAUpdates.web.ts +++ b/src/lib/hooks/useOTAUpdates.web.ts @@ -1,7 +1,11 @@ export function useOTAUpdates() {} +export function useOTAUpdateRecovery() {} export function useApplyPullRequestOTAUpdate() { return { - tryApplyUpdate: async (_channel: string) => {}, + tryApplyUpdate: async ( + _channel: string, + _declaredAppVersion?: string | null, + ) => {}, restoreDefaultChannel: async () => {}, isCurrentlyRunningPullRequestDeployment: false, isCurrentlyRunningNonStandardChannel: false, diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 33c6bc0b56..2f4424792d 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -63,6 +63,11 @@ export type Device = { activitySubscriptionsNudged?: boolean threadgateNudged?: boolean inviteFriendsFollowersPromoDismissed?: boolean + pendingOTAUpdate?: { + attemptedAt: number + channel: string + updateId: string + } /** * Selected color theme for the Invite Friends QR card. */ diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 126ce485b6..9603236d4d 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -9,6 +9,7 @@ import {useNavigation, useNavigationState} from '@react-navigation/native' import {useDedupe} from '#/lib/hooks/useDedupe' import {useIntentHandler} from '#/lib/hooks/useIntentHandler' import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler' +import {useOTAUpdateRecovery} from '#/lib/hooks/useOTAUpdates' import {useNotificationsRegistration} from '#/lib/notifications/notifications' import {isStateAtTabRoot} from '#/lib/routes/helpers' import {useDialogFullyExpandedCountContext} from '#/state/dialogs' @@ -216,6 +217,7 @@ export function Shell() { const fullyExpandedCount = useDialogFullyExpandedCountContext() useIntentHandler() + useOTAUpdateRecovery() useEffect(() => { setSystemUITheme('theme', t)