about summary refs log tree commit diff
diff options
context:
space:
mode:
authorhailey <me@haileyok.com>2025-07-24 16:23:17 -0700
committerGitHub <noreply@github.com>2025-07-24 16:23:17 -0700
commit1ee91d2c90ef84e0b6728e896292968562c8af01 (patch)
tree55dc5556d4f0f5382753a83bc98eb974cf12427d
parent9e65f00c937b156b876b0f6ca23dcef12b945dbe (diff)
downloadvoidsky-1ee91d2c90ef84e0b6728e896292968562c8af01.tar.zst
OTA deployments on PR comment action (#8713)
-rw-r--r--.github/workflows/pull-request-comment.yml177
-rw-r--r--app.config.js1
-rw-r--r--src/lib/hooks/useIntentHandler.ts23
-rw-r--r--src/lib/hooks/useOTAUpdates.ts166
-rw-r--r--src/screens/Settings/Settings.tsx16
5 files changed, 350 insertions, 33 deletions
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<NodeJS.Timeout>()
-  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<NavigationProp>()
   const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration()
+  const {
+    revertToEmbedded,
+    isCurrentlyRunningPullRequestDeployment,
+    currentChannel,
+  } = useApplyPullRequestOTAUpdate()
   const [actyNotifNudged, setActyNotifNudged] = useActivitySubscriptionsNudged()
 
   const resetOnboarding = async () => {
@@ -452,6 +459,15 @@ function DevOptions() {
           <Trans>Clear all storage data (restart after this)</Trans>
         </SettingsList.ItemText>
       </SettingsList.PressableItem>
+      {isNative && isCurrentlyRunningPullRequestDeployment ? (
+        <SettingsList.PressableItem
+          onPress={revertToEmbedded}
+          label={_(msg`Unapply Pull Request`)}>
+          <SettingsList.ItemText>
+            <Trans>Unapply Pull Request {currentChannel}</Trans>
+          </SettingsList.ItemText>
+        </SettingsList.PressableItem>
+      ) : null}
     </>
   )
 }