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:
-
+
- `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)