08d7946c49
Native nightly builds are redundant now that changes ship over the air, so the nightly workflow no longer builds anything. It now only posts the list of commits since the previous post to the QA team's Slack channel, and posts nothing when there are no new commits. The commit marker artifact keeps its name so the first run continues from the last nightly. The OTA workflow's fallback native iOS build is assigned to the "QA Team" TestFlight group so testers still get native builds when one is needed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157o74F6wX3aTghhc7DLbzd
325 lines
12 KiB
YAML
325 lines
12 KiB
YAML
---
|
|
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
|
|
changelog:
|
|
type: string
|
|
description: TestFlight "What to Test" notes (only applied when a group is selected)
|
|
required: false
|
|
default: ''
|
|
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
|
|
|
|
- name: 📝 Write build summary
|
|
env:
|
|
REMOTE_BUILD_NUMBER: ${{ steps.get-build-info.outputs.BSKY_IOS_BUILD_NUMBER }}
|
|
run: |
|
|
{
|
|
echo "### iOS build number"
|
|
echo
|
|
echo "\`$REMOTE_BUILD_NUMBER\`"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
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@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
|
|
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 }}
|
|
CHANGELOG: ${{ inputs.changelog }}
|
|
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
|
|
# changelog is optional, and passing it empty would blank out whatever "What to
|
|
# Test" notes the build already has, so only include the flag when it is set.
|
|
changelog_arg=()
|
|
if [ -n "$CHANGELOG" ]; then
|
|
changelog_arg=(changelog:"$CHANGELOG")
|
|
fi
|
|
# 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 \
|
|
"${changelog_arg[@]}"
|