Skip to content

fix(network): bound every request and surface the failure #1791

fix(network): bound every request and surface the failure

fix(network): bound every request and surface the failure #1791

Workflow file for this run

name: iOS Build
on:
pull_request:
types: [ready_for_review, synchronize, labeled]
push:
branches:
- development
concurrency:
# Keyed by ref so a PR build cannot cancel the development build, which is the
# one that uploads to TestFlight.
group: ios-build-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-and-test:
name: Lint & Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
repository: >
${{ github.event_name == 'push' && github.ref == 'refs/heads/development' && github.repository ||
github.event.pull_request.head.repo.full_name }}
ref: >
${{ github.event_name == 'push' && github.ref == 'refs/heads/development' && 'development' ||
github.event.pull_request.head.sha }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Lint
run: yarn lint
- name: Typecheck
run: yarn typecheck
- name: Unit tests
run: yarn test:ci
ios-build:
name: iOS Build
needs: lint-and-test
if: >
(github.event_name == 'pull_request') ||
(github.event_name == 'push' && github.ref == 'refs/heads/development')
runs-on: macos-26
steps:
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.3'
- name: Set Swift toolchain for Xcode 26
run: echo "TOOLCHAINS=com.apple.dt.toolchain.XcodeDefault" >> $GITHUB_ENV
- name: Show host machine information
run: |
uname -a
xcodebuild -showsdks
- name: Checkout PR head
uses: actions/checkout@v4
with:
repository: >
${{ github.event_name == 'push' && github.ref == 'refs/heads/development' && github.repository ||
github.event.pull_request.head.repo.full_name }}
ref: >
${{ github.event_name == 'push' && github.ref == 'refs/heads/development' && 'development' ||
github.event.pull_request.head.sha }}
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.0.0"
- name: Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
with:
version: 1.15.2
- name: Create Google Services PLIST File
env:
GOOGLE_PLIST: ${{ secrets.GOOGLE_PLIST }}
run: |
echo "$GOOGLE_PLIST" | base64 --decode -o ./ios/GoogleService-Info.plist
- name: Install the Apple certificate and provisioning profile
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.CERTIFICATES_P12 }}
P12_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.PROVISIONING_PROFILE }}
EXTEN_PROVISION_PROFILE_BASE64: ${{ secrets.EXTEN_PROVISIONING_PROFILE }}
NOTIFI_PROVISION_PROFILE_BASE64: ${{ secrets.NOTIFI_PROVISIONING_PROFILE }}
KEYCHAIN_PASSWORD: ${{ secrets.APPLE_APP_PASS }}
EXPORT_OPTIONS_PLIST: ${{ secrets.EXPORT_OPTIONS_PLIST }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
EX_PATH=$RUNNER_TEMP/build_ex.mobileprovision
NOT_PATH=$RUNNER_TEMP/build_not.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH
echo -n "$EXTEN_PROVISION_PROFILE_BASE64" | base64 --decode -o $EX_PATH
echo -n "$NOTIFI_PROVISION_PROFILE_BASE64" | base64 --decode -o $NOT_PATH
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
cp $EX_PATH ~/Library/MobileDevice/Provisioning\ Profiles
cp $NOT_PATH ~/Library/MobileDevice/Provisioning\ Profiles
for PROVISION in `ls $RUNNER_TEMP/*.mobileprovision`
do
UUID=`/usr/libexec/PlistBuddy -c 'Print :UUID' /dev/stdin <<< $(security cms -D -i $PROVISION)`
cp $PROVISION ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
done
- name: Verify signing certificate and provisioning profile expiry
# Fails fast and loudly when the iOS Distribution cert is missing or any
# provisioning profile is already expired (or expires within 7 days), so
# we don't bury the real cause inside a 1500-line xcodebuild log later.
env:
KEYCHAIN_PATH: ${{ runner.temp }}/app-signing.keychain-db
WARN_DAYS: '7'
run: |
set -euo pipefail
echo "::group::Code signing identities"
security find-identity -v -p codesigning "$KEYCHAIN_PATH" || true
echo "::endgroup::"
# Require at least one valid Apple/iPhone Distribution identity with a private key.
if ! security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
| grep -E '"(Apple|iPhone) Distribution' >/dev/null; then
echo "::error title=Distribution certificate missing::No valid 'Apple Distribution' or 'iPhone Distribution' identity with a private key was found in the build keychain. Re-export the .p12 from a Mac that has both the cert and its private key, base64-encode it, and update the CERTIFICATES_P12 secret. Also confirm CERTIFICATE_PASSWORD matches the .p12 export password."
exit 1
fi
NOW_EPOCH=$(date -u +%s)
WARN_SECS=$(( WARN_DAYS * 24 * 60 * 60 ))
FAIL=0
echo "::group::Provisioning profile expiry"
for PROVISION in "$RUNNER_TEMP"/*.mobileprovision; do
[ -f "$PROVISION" ] || continue
PLIST=$(security cms -D -i "$PROVISION")
NAME=$(/usr/libexec/PlistBuddy -c 'Print :Name' /dev/stdin <<< "$PLIST" 2>/dev/null || echo "(unknown)")
UUID=$(/usr/libexec/PlistBuddy -c 'Print :UUID' /dev/stdin <<< "$PLIST" 2>/dev/null || echo "(unknown)")
EXPIRATION=$(/usr/libexec/PlistBuddy -c 'Print :ExpirationDate' /dev/stdin <<< "$PLIST" 2>/dev/null || echo "")
if [ -z "$EXPIRATION" ]; then
echo "::error title=Provisioning profile unreadable::Could not read ExpirationDate from $(basename "$PROVISION") (name='$NAME', uuid='$UUID')"
FAIL=1
continue
fi
# PlistBuddy prints dates like "Fri Apr 10 16:43:34 GMT 2026" — parse to epoch.
EXP_EPOCH=$(date -u -j -f "%a %b %d %T %Z %Y" "$EXPIRATION" +%s 2>/dev/null || echo 0)
if [ "$EXP_EPOCH" = "0" ]; then
echo "::error title=Provisioning profile date parse failed::Could not parse ExpirationDate '$EXPIRATION' for profile '$NAME'"
FAIL=1
continue
fi
REMAINING=$(( EXP_EPOCH - NOW_EPOCH ))
REMAINING_DAYS=$(( REMAINING / 86400 ))
if [ "$REMAINING" -le 0 ]; then
echo "::error title=Provisioning profile expired::'$NAME' (uuid=$UUID) expired on $EXPIRATION. Regenerate it in the Apple Developer portal and update the matching GitHub secret (PROVISIONING_PROFILE / EXTEN_PROVISIONING_PROFILE / NOTIFI_PROVISIONING_PROFILE)."
FAIL=1
elif [ "$REMAINING" -le "$WARN_SECS" ]; then
echo "::warning title=Provisioning profile expiring soon::'$NAME' expires in $REMAINING_DAYS day(s) on $EXPIRATION. Regenerate before the next build."
else
echo "OK $NAME — $REMAINING_DAYS day(s) remaining (expires $EXPIRATION)"
fi
done
echo "::endgroup::"
if [ "$FAIL" -ne 0 ]; then
echo "::error::One or more provisioning profiles or signing certificates are invalid. See errors above."
exit 1
fi
- name: Create .env file
run: |
echo "ACTIVITY_WEBSOCKET_URL=${{ secrets.ACTIVITY_WEBSOCKET_URL }}" >> .env
echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> .env
echo "DEFAULT_PIN=${{ secrets.DEFAULT_PIN }}" >> .env
echo "ECENCY_BACKEND_API=${{ secrets.ECENCY_BACKEND_API }}" >> .env
echo "NEW_IMAGE_API=${{ secrets.NEW_IMAGE_API }}" >> .env
echo "PIN_KEY=${{ secrets.PIN_KEY }}" >> .env
echo "USER_AGENT=${{ secrets.USER_AGENT }}" >> .env
echo "PLAUSIBLE_API_KEY=${{ secrets.PLAUSIBLE_API_KEY }}" >> .env
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install npm dependency
run: yarn install
- name: Install Cocoapod dependencies
run: |
cd ios
pod install
- name: Set version code
run: |
TIMESTAMP=$(date +%s)
echo "Setting version code to: $TIMESTAMP"
sed -i '' "s/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = $TIMESTAMP/" ios/Ecency.xcodeproj/project.pbxproj
echo "IOS_BUILD_NUMBER=$TIMESTAMP" >> $GITHUB_ENV
- name: Resolve iOS Sentry release metadata
run: |
BUNDLE_ID=$(xcodebuild -workspace ios/Ecency.xcworkspace -scheme Ecency -configuration Release -showBuildSettings | awk '/ PRODUCT_BUNDLE_IDENTIFIER / {print $3; exit}')
VERSION=$(node -p "require('./package.json').version")
DIST="${IOS_BUILD_NUMBER}"
RELEASE="${BUNDLE_ID}@${VERSION}+${DIST}"
echo "SENTRY_RELEASE=$RELEASE" >> $GITHUB_ENV
echo "SENTRY_DIST=$DIST" >> $GITHUB_ENV
echo "Using Sentry release: $RELEASE"
echo "Using Sentry dist: $DIST"
- name: Build iOS JS Bundle
run: |
yarn run bundle:ios
- name: Copy JS bundle and sourcemap to temp directory
run: |
mkdir -p $RUNNER_TEMP/js
cp ios/main.jsbundle $RUNNER_TEMP/js/main.jsbundle
cp ios/main.jsbundle.map $RUNNER_TEMP/js/main.jsbundle.map
# Source maps are only used for error symbolication, so a transient Sentry
# API error (e.g. HTTP 500) must not block the release build. The shared
# retry helper retries transient failures; continue-on-error lets a
# sustained Sentry outage degrade gracefully instead of failing the build.
- name: Upload source maps to Sentry (iOS)
continue-on-error: true
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
retry="$GITHUB_WORKSPACE/.github/scripts/sentry-retry.sh"
bash "$retry" npx sentry-cli releases new "$SENTRY_RELEASE" --org ecency --project ecency-mobile
bash "$retry" npx sentry-cli releases files "$SENTRY_RELEASE" upload-sourcemaps \
--dist "$SENTRY_DIST" \
--org ecency --project ecency-mobile \
--rewrite \
--strip-common-prefix \
$RUNNER_TEMP/js/main.jsbundle $RUNNER_TEMP/js/main.jsbundle.map
bash "$retry" npx sentry-cli releases finalize "$SENTRY_RELEASE" --org ecency --project ecency-mobile
- name: Build archive
run: |
cd ios
xcodebuild -workspace Ecency.xcworkspace \
-scheme "Ecency" \
-sdk iphoneos \
-configuration Release \
-destination generic/platform=iOS \
-archivePath $RUNNER_TEMP/Ecency.xcarchive \
clean archive
- name: Upload iOS dSYMs to Sentry
continue-on-error: true
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
retry="$GITHUB_WORKSPACE/.github/scripts/sentry-retry.sh"
bash "$retry" npx sentry-cli debug-files upload \
--org ecency \
--project ecency-mobile \
--include-sources \
"$RUNNER_TEMP/Ecency.xcarchive/dSYMs"
- name: Export IPA
env:
EXPORT_OPTIONS_PLIST: ${{ secrets.EXPORT_OPTIONS_PLIST }}
run: |
EXPORT_OPTS_PATH=$RUNNER_TEMP/ExportOptions.plist
echo -n "$EXPORT_OPTIONS_PLIST" | base64 --decode -o $EXPORT_OPTS_PATH
xcodebuild -exportArchive \
-archivePath $RUNNER_TEMP/Ecency.xcarchive \
-exportOptionsPlist $EXPORT_OPTS_PATH \
-exportPath $RUNNER_TEMP/build
- name: List build contents
run: |
ls -la $RUNNER_TEMP/build
- name: Upload App
uses: actions/upload-artifact@v4
with:
name: Ecency-iOS
path: ${{ runner.temp }}/build/Ecency.ipa
retention-days: 90
ios-deploy:
name: Deploy iOS Build
needs: ios-build
# Only deploy if the trigger is a pull_request with 'deploy-pr-build' label or a push to 'development'
if: >
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy-pr-build')) ||
(github.event_name == 'push' && github.ref == 'refs/heads/development')
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Get iOS Build from artifacts
uses: actions/download-artifact@v4
- name: Upload to AppStoreConnect
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_PASS: ${{ secrets.APPLE_APP_PASS }}
run: xcrun altool --upload-app -f "Ecency-iOS/Ecency.ipa" -t ios -u "$APPLE_ID" -p "$APPLE_APP_PASS"