From 1ee91d2c90ef84e0b6728e896292968562c8af01 Mon Sep 17 00:00:00 2001 From: hailey Date: Thu, 24 Jul 2025 16:23:17 -0700 Subject: OTA deployments on PR comment action (#8713) --- .github/workflows/pull-request-comment.yml | 177 +++++++++++++++++++++++++++++ app.config.js | 1 - src/lib/hooks/useIntentHandler.ts | 23 +++- src/lib/hooks/useOTAUpdates.ts | 166 ++++++++++++++++++++++----- src/screens/Settings/Settings.tsx | 16 +++ 5 files changed, 350 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/pull-request-comment.yml diff --git a/.github/workflows/pull-request-comment.yml b/.github/workflows/pull-request-comment.yml new file mode 100644 index 000000000..351e518bc --- /dev/null +++ b/.github/workflows/pull-request-comment.yml @@ -0,0 +1,177 @@ +--- +name: PR Comment Trigger + +on: + issue_comment: + types: [created] + +jobs: + handle-comment: + if: github.event.issue.pull_request + runs-on: ubuntu-latest + outputs: + should-deploy: ${{ steps.check-org.outputs.result }} + + steps: + - name: Check if bot is mentioned + id: check-mention + run: | + if [[ "${{ github.event.comment.body }}" == *"@github-actions"* ]] || \ + [[ "${{ github.event.comment.body }}" == *"github-actions[bot]"* ]]; then + bot_mentioned=true + else + bot_mentioned=false + fi + + + if [[ "${{ github.event.comment.body }}" == *"ota"* ]]; then + has_ota=true + else + has_ota=false + fi + + + if [[ "$bot_mentioned" == "true" ]] && [[ "$has_ota" == "true" ]]; then + echo "mentioned=true" >> $GITHUB_OUTPUT + else + echo "mentioned=false" >> $GITHUB_OUTPUT + fi + + - name: Check organization membership + if: steps.check-mention.outputs.mentioned == 'true' + id: check-org + uses: actions/github-script@v7 + with: + script: | + try { + const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login + }); + + const hasAccess = ['admin', 'write'].includes(perm.permission); + console.log(`User has ${perm.permission} access`); + + return hasAccess; + } catch(error) { + console.log('User has no repository access'); + return false; + } + + bundle-deploy: + name: Bundle and Deploy EAS Update + runs-on: ubuntu-latest + needs: [handle-comment] + if: needs.handle-comment.outputs.should-deploy == 'true' + concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-deploy + cancel-in-progress: true + + steps: + - name: 💬 Drop a comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: pull-request-eas-build-${{ github.sha }} + message: | + An OTA deployment has been requested and is now running... + + [Here is some music to listen to while you wait...](https://www.youtube.com/watch?v=VBlFHuCzPgY) + --- + *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* + + - name: Check for EXPO_TOKEN + run: > + if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then + echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions" + exit 1 + fi + + - name: ⬇️ Checkout + uses: actions/checkout@v4 + + - name: 🔧 Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Lint check + run: yarn lint + + - name: Lint lockfile + run: yarn lockfile-lint + + - name: 🔤 Compile translations + run: yarn intl:build 2>&1 | tee i18n.log + + - name: Check for i18n compilation errors + run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi + + - name: Type check + run: yarn typecheck + + - name: 🔨 Setup EAS + uses: expo/expo-github-action@v8 + with: + expo-version: latest + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: ⛏️ Setup Expo + run: yarn global add eas-cli-local-build-plugin + + - name: 🪛 Setup jq + uses: dcarbone/install-jq-action@v2 + + - name: ✏️ Write environment variables + run: | + export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' + echo "${{ secrets.ENV_TOKEN }}" > .env + echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env + echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env + echo "BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env + echo "$json" > google-services.json + + - name: Setup Sentry vars for build-time injection + id: sentry + run: | + echo "SENTRY_DIST=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + echo "SENTRY_RELEASE=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT + + - name: 🏗️ Create Bundle + run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} EXPO_PUBLIC_ENV="pull-request" yarn export + + - name: 📦 Package Bundle and 🚀 Deploy + run: yarn use-build-number bash scripts/bundleUpdate.sh + env: + DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }} + CHANNEL_NAME: pull-request-${{ github.event.issue.number }} + + + - name: 💬 Drop a comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: pull-request-eas-build-${{ github.sha }} + message: | + Your requested OTA deployment was successful! You may now apply it by pressing the link below. + + [Apply OTA update](bluesky://ota-apply?channel=pull-request-${{ github.event.issue.number }}) + + [Here is some music to listen to while you wait...](https://www.youtube.com/watch?v=VBlFHuCzPgY) + --- + *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* + + + - name: 💬 Drop a comment + uses: marocchino/sticky-pull-request-comment@v2 + if: failure() + with: + header: pull-request-eas-build-${{ github.sha }} + message: | + Your requested OTA deployment was unsuccessful. See action logs for more details. + --- + *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* diff --git a/app.config.js b/app.config.js index cbe028273..e065d0d73 100644 --- a/app.config.js +++ b/app.config.js @@ -190,7 +190,6 @@ module.exports = function (_config) { } : undefined, checkAutomatically: 'NEVER', - channel: UPDATES_CHANNEL, }, plugins: [ 'expo-video', diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index 6b1083aa4..f55217e56 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -1,8 +1,9 @@ import React from 'react' +import {Alert} from 'react-native' import * as Linking from 'expo-linking' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {logEvent} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import {isNative} from '#/platform/detection' import {useSession} from '#/state/session' import {useCloseAllActiveElements} from '#/state/util' @@ -12,8 +13,10 @@ import { } from '#/components/ageAssurance/AgeAssuranceRedirectDialog' import {useIntentDialogs} from '#/components/intents/IntentDialogs' import {Referrer} from '../../../modules/expo-bluesky-swiss-army' +import {IS_TESTFLIGHT} from '../app-info.web' +import {useApplyPullRequestOTAUpdate} from './useOTAUpdates' -type IntentType = 'compose' | 'verify-email' | 'age-assurance' +type IntentType = 'compose' | 'verify-email' | 'age-assurance' | 'apply-ota' const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/ @@ -27,12 +30,13 @@ export function useIntentHandler() { const ageAssuranceRedirectDialogControl = useAgeAssuranceRedirectDialogControl() const {currentAccount} = useSession() + const {tryApplyUpdate} = useApplyPullRequestOTAUpdate() React.useEffect(() => { const handleIncomingURL = (url: string) => { const referrerInfo = Referrer.getReferrerInfo() if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { - logEvent('deepLink:referrerReceived', { + logger.metric('deepLink:referrerReceived', { to: url, referrer: referrerInfo?.referrer, hostname: referrerInfo?.hostname, @@ -92,6 +96,18 @@ export function useIntentHandler() { } return } + case 'apply-ota': { + if (!isNative || !IS_TESTFLIGHT) { + return + } + + const channel = params.get('channel') + if (!channel) { + Alert.alert('Error', 'No channel provided to look for.') + } else { + tryApplyUpdate(channel) + } + } default: { return } @@ -111,6 +127,7 @@ export function useIntentHandler() { verifyEmailIntent, ageAssuranceRedirectDialogControl, currentAccount, + tryApplyUpdate, ]) } diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts index 731406dce..72f215fa9 100644 --- a/src/lib/hooks/useOTAUpdates.ts +++ b/src/lib/hooks/useOTAUpdates.ts @@ -1,5 +1,5 @@ import React from 'react' -import {Alert, AppState, AppStateStatus} from 'react-native' +import {Alert, AppState, type AppStateStatus} from 'react-native' import {nativeBuildVersion} from 'expo-application' import { checkForUpdateAsync, @@ -29,6 +29,128 @@ async function setExtraParams() { ) } +async function setExtraParamsPullRequest(channel: string) { + await setExtraParamAsync( + isIOS ? 'ios-build-number' : 'android-build-number', + // Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is. + // This just ensures it gets passed as a string + `${nativeBuildVersion}`, + ) + await setExtraParamAsync('channel', channel) +} + +async function updateTestflight() { + await setExtraParams() + + const res = await checkForUpdateAsync() + if (res.isAvailable) { + await fetchUpdateAsync() + + Alert.alert( + 'Update Available', + 'A new version of the app is available. Relaunch now?', + [ + { + text: 'No', + style: 'cancel', + }, + { + text: 'Relaunch', + style: 'default', + onPress: async () => { + await reloadAsync() + }, + }, + ], + ) + } +} + +export function useApplyPullRequestOTAUpdate() { + const {currentlyRunning} = useUpdates() + const [pending, setPending] = React.useState(false) + const currentChannel = currentlyRunning?.channel + const isCurrentlyRunningPullRequestDeployment = + currentChannel?.startsWith('pull-request') + + const tryApplyUpdate = async (channel: string) => { + setPending(true) + if (currentChannel === channel) { + const res = await checkForUpdateAsync() + if (res.isAvailable) { + logger.debug('Attempting to fetch update...') + await fetchUpdateAsync() + Alert.alert( + 'Deployment Available', + `A new deployment of ${channel} is availalble. Relaunch now?`, + [ + { + text: 'No', + style: 'cancel', + }, + { + text: 'Relaunch', + style: 'default', + onPress: async () => { + await reloadAsync() + }, + }, + ], + ) + } else { + Alert.alert( + 'No Deployment Available', + `No new deployments of ${channel} are currently available for your current native build.`, + ) + } + } else { + setExtraParamsPullRequest(channel) + const res = await checkForUpdateAsync() + if (res.isAvailable) { + 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?`, + [ + { + text: 'No', + style: 'cancel', + }, + { + text: 'Relaunch', + style: 'default', + onPress: async () => { + await reloadAsync() + }, + }, + ], + ) + } else { + Alert.alert( + 'No Deployment Available', + `No new deployments of ${channel} are currently available for your current native build.`, + ) + } + } + setPending(false) + } + + const revertToEmbedded = async () => { + try { + await updateTestflight() + } catch (e: any) { + logger.error('Internal OTA Update Error', {error: `${e}`}) + } + } + + return { + tryApplyUpdate, + revertToEmbedded, + currentChannel, + isCurrentlyRunningPullRequestDeployment, + pending, + } +} + export function useOTAUpdates() { const shouldReceiveUpdates = isEnabled && !__DEV__ @@ -36,7 +158,8 @@ export function useOTAUpdates() { const lastMinimize = React.useRef(0) const ranInitialCheck = React.useRef(false) const timeout = React.useRef() - const {isUpdatePending} = useUpdates() + const {currentlyRunning, isUpdatePending} = useUpdates() + const currentChannel = currentlyRunning?.channel const setCheckTimeout = React.useCallback(() => { timeout.current = setTimeout(async () => { @@ -60,36 +183,18 @@ export function useOTAUpdates() { const onIsTestFlight = React.useCallback(async () => { try { - await setExtraParams() - - const res = await checkForUpdateAsync() - if (res.isAvailable) { - await fetchUpdateAsync() - - Alert.alert( - 'Update Available', - 'A new version of the app is available. Relaunch now?', - [ - { - text: 'No', - style: 'cancel', - }, - { - text: 'Relaunch', - style: 'default', - onPress: async () => { - await reloadAsync() - }, - }, - ], - ) - } + await updateTestflight() } catch (e: any) { logger.error('Internal OTA Update Error', {error: `${e}`}) } }, []) React.useEffect(() => { + // We don't need to check anything if the current update is a PR update + if (currentChannel?.startsWith('pull-request')) { + return + } + // We use this setTimeout to allow Statsig to initialize before we check for an update // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update @@ -103,12 +208,15 @@ export function useOTAUpdates() { setCheckTimeout() ranInitialCheck.current = true - }, [onIsTestFlight, setCheckTimeout, shouldReceiveUpdates]) + }, [onIsTestFlight, currentChannel, setCheckTimeout, shouldReceiveUpdates]) // After the app has been minimized for 15 minutes, we want to either A. install an update if one has become available // or B check for an update again. React.useEffect(() => { - if (!isEnabled) return + // We also don't start this timeout if the user is on a pull request update + if (!isEnabled || currentChannel?.startsWith('pull-request')) { + return + } const subscription = AppState.addEventListener( 'change', @@ -138,5 +246,5 @@ export function useOTAUpdates() { clearTimeout(timeout.current) subscription.remove() } - }, [isUpdatePending, setCheckTimeout]) + }, [isUpdatePending, currentChannel, setCheckTimeout]) } diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 4d10a9d0d..9596c2479 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -12,12 +12,14 @@ import {useActorStatus} from '#/lib/actor-status' import {IS_INTERNAL} from '#/lib/app-info' import {HELP_DESK_URL} from '#/lib/constants' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' +import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates' import { type CommonNavigatorParams, type NavigationProp, } from '#/lib/routes/types' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' +import {isNative} from '#/platform/detection' import {useProfileShadow} from '#/state/cache/profile-shadow' import * as persisted from '#/state/persisted' import {clearStorage} from '#/state/persisted' @@ -364,6 +366,11 @@ function DevOptions() { const onboardingDispatch = useOnboardingDispatch() const navigation = useNavigation() const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration() + const { + revertToEmbedded, + isCurrentlyRunningPullRequestDeployment, + currentChannel, + } = useApplyPullRequestOTAUpdate() const [actyNotifNudged, setActyNotifNudged] = useActivitySubscriptionsNudged() const resetOnboarding = async () => { @@ -452,6 +459,15 @@ function DevOptions() { Clear all storage data (restart after this) + {isNative && isCurrentlyRunningPullRequestDeployment ? ( + + + Unapply Pull Request {currentChannel} + + + ) : null} ) } -- cgit 1.4.1