handle pr ota failures

This commit is contained in:
Samuel Newman
2026-07-29 21:03:39 +03:00
parent 15c6c7955d
commit 10e023829b
8 changed files with 398 additions and 26 deletions
+13 -2
View File
@@ -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:
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.pull_request.number }}" width="300" height="300" alt="QR code for the PR OTA deployment">
<img src="https://bsky-qr.vercel.app?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 }}" width="300" height="300" alt="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 }}`
+1 -1
View File
@@ -707,7 +707,7 @@
"count": 1
},
"typescript/no-misused-promises": {
"count": 4
"count": 3
}
},
"src/lib/hooks/usePermissions.ts": {
+9 -1
View File
@@ -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
}
+215
View File
@@ -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<typeof useUpdates>)
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<ReturnType<typeof checkForUpdateAsync>>)
jest.mocked(fetchUpdateAsync).mockResolvedValue({
isNew: true,
isRollBackToEmbedded: false,
manifest: {id: 'mismatched-update'},
} as Awaited<ReturnType<typeof fetchUpdateAsync>>)
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<ReturnType<typeof checkForUpdateAsync>>)
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<ReturnType<typeof checkForUpdateAsync>>)
jest.mocked(fetchUpdateAsync).mockResolvedValue({
isNew: true,
isRollBackToEmbedded: false,
manifest: {id: 'new-update'},
} as Awaited<ReturnType<typeof fetchUpdateAsync>>)
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('silently clears the marker when the attempted OTA launched', () => {
jest.mocked(device.get).mockReturnValue({
attemptedAt: Date.now(),
channel: 'pull-request-123',
updateId: currentUpdate.updateId,
})
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()
})
})
+148 -21
View File
@@ -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,45 @@ 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 === currentlyRunning.updateId) 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__
+5 -1
View File
@@ -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,
+5
View File
@@ -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.
*/
+2
View File
@@ -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)