--- name: Build and Submit iOS on: workflow_dispatch: inputs: profile: type: choice description: Build profile to use options: - testflight - production testFlightGroup: type: choice description: TestFlight group to assign the build to after submitting options: - none - QA Team - Software Mansion default: none workflow_call: inputs: profile: type: string description: Build profile to use required: true testFlightGroup: type: string description: TestFlight group to assign the build to after submitting ("none" to skip) default: none runner: type: string description: Runner for the build job (defaults to macos-26-xlarge) required: false default: '' outputs: package-version: description: Version from package.json value: ${{ jobs.build.outputs.package-version }} build-number: description: iOS build number value: ${{ jobs.build.outputs.build-number }} secrets: EXPO_TOKEN: required: true ENV_TOKEN: required: true SENTRY_DSN: required: true BITDRIFT_API_KEY: required: true EXPO_PUBLIC_GCP_PROJECT_ID: required: true GOOGLE_SERVICES_TOKEN: required: true SENTRY_AUTH_TOKEN: required: true ASC_KEY_ID: required: true ASC_ISSUER_ID: required: true ASC_KEY_P8_BASE64: required: true SLACK_CLIENT_ALERT_WEBHOOK: required: true # Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code permissions: contents: read jobs: build: if: github.repository == 'bluesky-social/social-app' name: Build iOS runs-on: ${{ inputs.runner || 'macos-26-xlarge' }} concurrency: group: ios-build cancel-in-progress: false outputs: package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }} build-number: ${{ steps.ipa-build-number.outputs.build-number }} steps: - name: โฌ‡๏ธ Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 5 - name: ๐Ÿ”ง Setup Expo project uses: ./.github/actions/setup-expo-project with: expo-token: ${{ secrets.EXPO_TOKEN }} - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: "26.4" - name: โ˜•๏ธ Assert Cocoapods version run: | EXPECTED=1.17.0 ACTUAL=$(pod --version) if [ "$ACTUAL" != "$EXPECTED" ]; then echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL." echo "The version ships preinstalled with the macOS runner image: https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md" echo "If the runner image changed, update EXPECTED here or reinstall the pinned version." exit 1 fi - name: ๐Ÿ’พ Cache Pods uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: pods-cache with: path: ./ios/Pods # We'll use the pnpm-lock.yaml for our hash since we don't yet have a Podfile.lock. Pod versions will not # change unless the pnpm version changes as well. key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }} - name: ๐Ÿ”ค Compile translations uses: ./.github/actions/compile-i18n # EXPO_PUBLIC_ENV is handled in eas.json - name: โœ๏ธ Write environment variables id: env uses: ./.github/actions/write-env with: env-token: ${{ secrets.ENV_TOKEN }} sentry-dsn: ${{ secrets.SENTRY_DSN }} bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }} gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }} google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }} - name: ๐Ÿ—๏ธ EAS Build uses: ./.github/actions/eas-local-build with: platform: ios profile: ${{ inputs.profile || 'testflight' }} output: build.tar.gz bump-build-number: "true" sentry-auth-token: ${{ secrets.SENTRY_AUTH_TOKEN }} sentry-release: ${{ steps.env.outputs.release-version }} sentry-dist: ${{ steps.env.outputs.bundle-identifier }} - name: ๐Ÿ“‚ Extract build artifact run: | if [ -f "build.tar.gz" ]; then echo "Extracting build.tar.gz..." rm -rf ios-build mkdir -p ios-build tar -xzf build.tar.gz -C ios-build echo "Extraction completed successfully" echo "" echo "Top-level extracted files:" find ios-build -maxdepth 3 -print echo "" echo "Searching for IPA..." IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)" if [ -z "$IPA_PATH" ]; then echo "ERROR: No .ipa found anywhere under ios-build." echo "Archive contents:" tar -tzf build.tar.gz | sed -n '1,200p' exit 1 fi BUILD_DIR="$(dirname "$IPA_PATH")" echo "Found IPA at: $IPA_PATH" echo "Build dir: $BUILD_DIR" echo "" echo "Build dir contents:" ls -la "$BUILD_DIR" echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV else echo "Archive file not found!" exit 1 fi - name: ๐Ÿ“š Get version from package.json id: get-build-info run: bash scripts/setGitHubOutput.sh # Read the build number straight from the IPA's CFBundleVersion. This is the value # baked in at build time by use-build-number-with-bump (remote counter + 1) and the # number that actually lands in App Store Connect. `eas build:version:get` reads the # remote counter, which a --local build does not advance, so it can be off by one โ€” # using it here would make distribute_only poll for a nonexistent build. # PlistBuddy is macOS-only, which is why this stays in the build job. - name: ๐Ÿ”ข Read build number from IPA id: ipa-build-number run: | plist_dir="$(mktemp -d)" unzip -o -q "$BUILD_DIR/Bluesky.ipa" 'Payload/*.app/Info.plist' -d "$plist_dir" plist="$(find "$plist_dir" -name Info.plist -print -quit)" build_number="$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' "$plist")" rm -rf "$plist_dir" if [ -z "$build_number" ]; then echo "ERROR: could not read CFBundleVersion from IPA" exit 1 fi echo "IPA build number: $build_number" echo "build-number=$build_number" >> "$GITHUB_OUTPUT" # Hand the IPA and dSYM off to the submit job. Retention is deliberately short since # this artifact only exists to bridge the two jobs within a single run. - name: ๐Ÿš€ Upload build artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ios-build-${{ github.run_id }} retention-days: 1 if-no-files-found: error path: | ${{ env.BUILD_DIR }}/Bluesky.ipa ${{ env.BUILD_DIR }}/Bluesky.app.dSYM.zip submit: name: Submit iOS # Submission and dSYM upload are I/O bound and don't need the xlarge builder. runs-on: macos-26 needs: [build] steps: - name: โฌ‡๏ธ Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # eas submit reads the app config from the repo fetch-depth: 5 - name: ๐Ÿ”ง Setup Expo project uses: ./.github/actions/setup-expo-project with: expo-token: ${{ secrets.EXPO_TOKEN }} - name: โฌ‡๏ธ Download build artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ios-build-${{ github.run_id }} path: ios-build - name: ๐Ÿš€ Deploy run: pnpm eas submit -p ios --non-interactive --path ios-build/Bluesky.ipa - name: ๐Ÿชฒ Upload dSYM to Sentry env: SENTRY_ORG: blueskyweb SENTRY_PROJECT: app SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} run: pnpm sentry-cli debug-files upload ios-build/Bluesky.app.dSYM.zip --include-sources - name: ๐Ÿ”” Notify Slack of Production Build if: ${{ inputs.profile == 'production' }} uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook-type: incoming-webhook payload-templated: true payload: | {"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.build-number }}```"} distribute: name: Assign build to TestFlight group # fastlane and jq ship preinstalled on the macOS runner image, and this step mostly idles # polling Apple processing, so it runs on a normal-size runner. runs-on: macos-26 needs: [build, submit] # testFlightGroup defaults to 'none' on both workflow_call and dispatch; guard against the # empty string too, since `!= 'none'` alone would be true for ''. if: ${{ inputs.testFlightGroup && inputs.testFlightGroup != 'none' }} steps: # eas submit only uploads to App Store Connect; it can't assign a build to a # TestFlight group. fastlane's distribute_only mode skips the upload and assigns the # already-submitted build to the group, polling until Apple finishes processing it. - name: ๐Ÿงช Assign build to TestFlight group env: TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }} ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }} APP_VERSION: ${{ needs.build.outputs.package-version }} BUILD_NUMBER: ${{ needs.build.outputs.build-number }} run: | # Ensure the API key material is removed even if fastlane exits non-zero # (the step runs under `bash -e`, which would otherwise abort before cleanup). trap 'rm -f asc_api_key.json' EXIT # fastlane's Token.from_json_file expects the .p8 contents inline under "key" # (PEM with embedded newlines), not a path. jq handles the newline escaping. key_content="$(echo "$ASC_KEY_P8_BASE64" | base64 --decode)" jq -n \ --arg key_id "$ASC_KEY_ID" \ --arg issuer_id "$ASC_ISSUER_ID" \ --arg key "$key_content" \ '{key_id: $key_id, issuer_id: $issuer_id, key: $key, in_house: false}' \ > asc_api_key.json # app_platform is required in non-interactive mode: distribute_only otherwise # calls fetch_app_platform, which prompts for input and crashes without a TTY. fastlane run upload_to_testflight \ api_key_path:"$PWD/asc_api_key.json" \ distribute_only:true \ app_platform:"ios" \ app_identifier:"xyz.blueskyweb.app" \ app_version:"$APP_VERSION" \ build_number:"$BUILD_NUMBER" \ groups:"$TESTFLIGHT_GROUP" \ notify_external_testers:true