name: Mobile Deploy env: # Build environment versions RUBY_VERSION: 3.2 JAVA_VERSION: 17 ANDROID_API_LEVEL: 35 ANDROID_NDK_VERSION: 27.0.12077973 XCODE_VERSION: 16.4 # Cache versioning - increment these to bust caches when needed GH_CACHE_VERSION: v1 # Global cache version GH_YARN_CACHE_VERSION: v1 # Yarn-specific cache version GH_GEMS_CACHE_VERSION: v1 # Ruby gems cache version GH_PODS_CACHE_VERSION: v1 # CocoaPods cache version GH_GRADLE_CACHE_VERSION: v1 # Gradle cache version # Path configuration WORKSPACE: ${{ github.workspace }} APP_PATH: ${{ github.workspace }}/app # Certificate/keystore paths ANDROID_KEYSTORE_PATH: /android/app/upload-keystore.jks ANDROID_PLAY_STORE_JSON_KEY_PATH: /android/play-store-key.json IOS_DIST_CERT_PATH: /ios/certs/dist_cert.p12 IOS_CONNECT_API_KEY_PATH: /ios/certs/connect_api_key.p8 IOS_PROV_PROFILE_PROJ_PATH: /ios/certs/profile.mobileprovision IOS_PROV_PROFILE_DIRECTORY: "~/Library/MobileDevice/Provisioning\ Profiles/" permissions: contents: write pull-requests: write id-token: write on: workflow_dispatch: inputs: platform: description: "Select platform to build" required: true default: "both" type: choice options: - ios - android - both test_mode: description: "Test mode (skip upload to stores)" required: false type: boolean default: false deployment_track: description: "Deployment track (internal/production)" required: false type: choice default: "internal" options: - internal - production version_bump: description: "Version bump type" required: false type: choice default: "build" options: - build - patch - minor - major workflow_call: inputs: platform: type: string required: true deployment_track: type: string required: false default: "internal" version_bump: type: string required: false default: "build" auto_deploy: type: boolean required: false default: false test_mode: type: boolean required: false default: false concurrency: group: mobile-deploy-${{ inputs.deployment_track || github.ref_name }} cancel-in-progress: false jobs: build-ios: runs-on: macos-latest-large if: (inputs.platform == 'ios' || inputs.platform == 'both') steps: - name: Mobile deployment status run: | echo "๐Ÿš€ Mobile deployment is enabled - proceeding with iOS build" echo "๐Ÿ“ฑ Platform: ${{ inputs.platform }}" echo "๐ŸŽฏ Track: ${{ inputs.deployment_track }}" echo "๐Ÿ“ฆ Version bump: ${{ inputs.version_bump }}" - uses: actions/checkout@v4 with: fetch-depth: 0 ref: staging - name: Read and sanitize Node.js version shell: bash run: | if [ ! -f .nvmrc ] || [ -z "$(cat .nvmrc)" ]; then echo "โŒ .nvmrc is missing or empty"; exit 1; fi VERSION="$(tr -d '\r\n' < .nvmrc)" VERSION="${VERSION#v}" if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then echo "Invalid .nvmrc content: '$VERSION'"; exit 1; fi echo "NODE_VERSION=$VERSION" >> "$GITHUB_ENV" echo "NODE_VERSION_SANITIZED=${VERSION//\//-}" >> "$GITHUB_ENV" - name: Verify branch and commit (iOS) if: inputs.platform != 'android' run: | echo "๐Ÿ” Verifying we're building from the correct branch and commit..." echo "Current branch: $(git branch --show-current || git symbolic-ref --short HEAD 2>/dev/null || echo 'detached')" echo "Current commit: $(git rev-parse HEAD)" echo "Current commit message: $(git log -1 --pretty=format:'%s')" echo "Staging HEAD commit: $(git rev-parse origin/staging)" echo "Staging HEAD message: $(git log -1 --pretty=format:'%s' origin/staging)" if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/staging)" ]; then echo "โš ๏ธ WARNING: Current commit differs from latest staging commit" echo "This might indicate we're not building from the latest staging branch" git log --oneline HEAD..origin/staging || true else echo "โœ… Building from latest staging commit" fi - name: Set up Xcode if: inputs.platform != 'android' uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: ${{ env.XCODE_VERSION }} - name: Configure Xcode path if: inputs.platform != 'android' run: | echo "๐Ÿ”ง Configuring Xcode path to fix iOS SDK issues..." # Fix for macOS 15 runner iOS SDK issues # See: https://github.com/actions/runner-images/issues/12758 sudo xcode-select --switch /Applications/Xcode_${{ env.XCODE_VERSION }}.app echo "โœ… Xcode path configured" # Verify Xcode setup echo "Xcode version:" xcodebuild -version echo "Xcode path:" xcode-select -p - name: Cache Yarn artifacts id: yarn-cache uses: ./.github/actions/cache-yarn with: path: | .yarn/cache .yarn/install-state.gz .yarn/unplugged cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }} - name: Cache Ruby gems id: gems-cache uses: ./.github/actions/cache-bundler with: path: ${{ env.APP_PATH }}/ios/vendor/bundle key: ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-${{ hashFiles('app/Gemfile.lock') }} restore-keys: | ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-${{ hashFiles('app/Gemfile.lock') }}- ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}- ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}- - name: Cache CocoaPods id: pods-cache uses: ./.github/actions/cache-pods with: path: | ${{ env.APP_PATH }}/ios/Pods ~/Library/Caches/CocoaPods lock-file: app/ios/Podfile.lock cache-version: ${{ env.GH_CACHE_VERSION }}-${{ env.GH_PODS_CACHE_VERSION }} - name: Log cache status run: | echo "Cache hit results:" echo "- Yarn cache hit: ${{ steps.yarn-cache.outputs.cache-hit }}" echo "- Gems cache hit: ${{ steps.gems-cache.outputs.cache-hit }}" echo "- Pods cache hit: ${{ steps.pods-cache.outputs.cache-hit }}" - name: Disable Yarn hardened mode run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV - name: Verify lock files are up to date run: | echo "๐Ÿ” Checking if lock files are in sync with dependency files..." # For yarn workspaces, yarn.lock is at root if [ ! -f "${{ env.WORKSPACE }}/yarn.lock" ]; then echo "โŒ ERROR: yarn.lock file is missing at workspace root!" echo "Run 'yarn install' at the repository root and commit the yarn.lock file." exit 1 fi # Gemfile.lock is in app directory if [ ! -f "${{ env.APP_PATH }}/Gemfile.lock" ]; then echo "โŒ ERROR: Gemfile.lock file is missing!" echo "Run 'bundle install' in the app directory and commit the Gemfile.lock file." exit 1 fi echo "โœ… Lock files exist" - name: Install Mobile Dependencies (main repo) if: inputs.platform != 'android' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) uses: ./.github/actions/mobile-setup with: app_path: ${{ env.APP_PATH }} node_version: ${{ env.NODE_VERSION }} ruby_version: ${{ env.RUBY_VERSION }} workspace: ${{ env.WORKSPACE }} env: SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }} - name: Install Mobile Dependencies (forked PRs - no secrets) if: inputs.platform != 'android' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true uses: ./.github/actions/mobile-setup with: app_path: ${{ env.APP_PATH }} node_version: ${{ env.NODE_VERSION }} ruby_version: ${{ env.RUBY_VERSION }} workspace: ${{ env.WORKSPACE }} - name: Verify iOS Secrets if: inputs.platform != 'android' run: | # Verify App Store Connect API Key exists and contains PEM header if [ -z "${{ secrets.IOS_CONNECT_API_KEY_BASE64 }}" ]; then echo "โŒ Error: App Store Connect API Key cannot be empty" exit 1 fi # Verify Issuer ID is in correct format (UUID) if ! echo "${{ secrets.IOS_CONNECT_ISSUER_ID }}" | grep -E "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" >/dev/null; then echo "โŒ Error: Invalid App Store Connect Issuer ID format (should be UUID)" exit 1 fi # Verify Key ID is in correct format (alphanumeric) if ! echo "${{ secrets.IOS_CONNECT_KEY_ID }}" | grep -E "^[A-Z0-9]{10}$" >/dev/null; then echo "โŒ Error: Invalid App Store Connect Key ID format" exit 1 fi # Verify P12 password is not empty and meets basic security requirements if [ -z "${{ secrets.IOS_P12_PASSWORD }}" ]; then echo "โŒ Error: P12 password cannot be empty" exit 1 fi # Verify base64 secrets are not empty if [ -z "${{ secrets.IOS_DIST_CERT_BASE64 }}" ]; then echo "โŒ Error: Distribution certificate cannot be empty" exit 1 fi if [ -z "${{ secrets.IOS_PROV_PROFILE_BASE64 }}" ]; then echo "โŒ Error: Provisioning profile cannot be empty" exit 1 fi echo "โœ… All iOS secrets verified successfully!" - name: Decode certificate and profile if: inputs.platform != 'android' run: | mkdir -p "${{ env.APP_PATH }}$(dirname "${{ env.IOS_DIST_CERT_PATH }}")" echo "${{ secrets.IOS_DIST_CERT_BASE64 }}" | base64 --decode > ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }} echo "${{ secrets.IOS_PROV_PROFILE_BASE64 }}" | base64 --decode > ${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }} echo "${{ secrets.IOS_CONNECT_API_KEY_BASE64 }}" | base64 --decode > ${{ env.APP_PATH }}${{ env.IOS_CONNECT_API_KEY_PATH }} # for debugging...which can take some time :( - name: Verify ios secret checksums if: false # for debugging run: | echo "SHA256 of dist_cert.p12:" shasum -a 256 ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }} echo "SHA256 of profile.mobileprovision:" shasum -a 256 ${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }} echo "SHA256 of connect_api_key.p8:" shasum -a 256 ${{ env.APP_PATH }}${{ env.IOS_CONNECT_API_KEY_PATH }} echo "Certificate file size:" ls -l ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }} echo "SHA256 of password:" echo -n "${{ secrets.IOS_P12_PASSWORD }}" | shasum -a 256 echo "SHA256 of connect_api_key_base64:" echo -n "${{ secrets.IOS_CONNECT_API_KEY_BASE64 }}" | shasum -a 256 echo "Verifying certificate..." if openssl pkcs12 -in ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }} -password pass:'${{ secrets.IOS_P12_PASSWORD }}' -info >/dev/null 2>&1 || openssl pkcs12 -in ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }} -password pass:'${{ secrets.IOS_P12_PASSWORD }}' -info 2>&1 | grep -q "MAC:"; then echo "โœ… Certificate verification successful (algorithm warning can be safely ignored)" else echo "โŒ Certificate verification failed - please check certificate validity" exit 1 fi - name: Verify iOS certificate and environment if: inputs.platform != 'android' && !env.ACT run: | # Check if certificate directory exists if [ ! -d "${{ env.APP_PATH }}/ios/certs" ]; then echo "โŒ Error: iOS certificates directory not found at ${{ env.APP_PATH }}/ios/certs" exit 1 fi # Check if certificate file exists if [ ! -f "${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }}" ]; then echo "โŒ Error: Distribution certificate not found at ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }}" exit 1 fi # Check certificate file permissions CERT_PERMS=$(ls -l "${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }}" | awk '{print $1}') if [ "$CERT_PERMS" != "-rw-r--r--" ]; then echo "โŒ Error: Distribution certificate has incorrect permissions: $CERT_PERMS" echo "Expected: -rw-r--r--" exit 1 fi # Check certificate file size CERT_SIZE=$(stat -f%z "${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }}" 2>/dev/null || stat -c%s "${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }}") if [ "$CERT_SIZE" -lt 1000 ]; then echo "โŒ Error: Distribution certificate file size ($CERT_SIZE bytes) is suspiciously small" exit 1 fi # Check if we can create a test keychain TEST_KEYCHAIN="test.keychain" if ! security create-keychain -p "" "$TEST_KEYCHAIN" >/dev/null 2>&1; then echo "โŒ Error: Unable to create test keychain. Check permissions." exit 1 fi security delete-keychain "$TEST_KEYCHAIN" >/dev/null 2>&1 echo "โœ… Certificate and environment verification passed!" - name: Install certificate if: inputs.platform != 'android' && !env.ACT run: | security create-keychain -p "" build.keychain >/dev/null 2>&1 security default-keychain -s build.keychain >/dev/null 2>&1 security unlock-keychain -p "" build.keychain >/dev/null 2>&1 security import ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }} -k build.keychain -P '${{ secrets.IOS_P12_PASSWORD }}' -T /usr/bin/codesign >/dev/null 2>&1 security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain >/dev/null 2>&1 - name: Install provisioning profile if: inputs.platform != 'android' && !env.ACT env: IOS_APP_IDENTIFIER: ${{ secrets.IOS_APP_IDENTIFIER }} IOS_PROV_PROFILE_NAME: ${{ secrets.IOS_PROV_PROFILE_NAME }} IOS_PROV_PROFILE_PATH: ${{ env.IOS_PROV_PROFILE_PATH }} IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }} run: | # Verify file exists before proceeding echo "Checking for provisioning profile at: ${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" ls -l "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" if [ ! -f "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" ]; then echo "โŒ Error: Provisioning profile not found at specified path." exit 1 fi echo "Provisioning profile found." # Print file details echo "Provisioning Profile File Details:" echo "--------------------------------" echo "File size: $(stat -f%z "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" 2>/dev/null || stat -c%s "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}") bytes" echo "File permissions: $(ls -l "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" | awk '{print $1}')" echo "File owner: $(ls -l "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" | awk '{print $3}')" echo "--------------------------------" # Create a temporary plist file to extract UUID TEMP_PLIST_PATH=$(mktemp /tmp/profile_plist.XXXXXX) # Extract plist from mobileprovision file echo "Extracting plist from provisioning profile..." security cms -D -i "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" -o "$TEMP_PLIST_PATH" if [ $? -ne 0 ]; then echo "โŒ Error: Failed to extract plist from provisioning profile" exit 1 fi # Extract UUID and profile name from plist echo "Extracting UUID and profile name from plist..." PROFILE_UUID=$(/usr/libexec/PlistBuddy -c "Print :UUID" "$TEMP_PLIST_PATH" 2>/dev/null) if [ $? -ne 0 ] || [ -z "$PROFILE_UUID" ]; then echo "โŒ Error: Failed to extract UUID from provisioning profile" cat "$TEMP_PLIST_PATH" | head -20 exit 1 fi # Extract the actual profile name from within the file PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$TEMP_PLIST_PATH" 2>/dev/null) if [ $? -ne 0 ] || [ -z "$PROFILE_NAME" ]; then echo "โš ๏ธ Warning: Failed to extract Name from provisioning profile, will use provided IOS_PROV_PROFILE_NAME" PROFILE_NAME="$IOS_PROV_PROFILE_NAME" fi echo "Profile UUID: $PROFILE_UUID" echo "Profile Name: $PROFILE_NAME" # Install provisioning profile in the correct location with UUID filename echo "Installing provisioning profile in filesystem..." mkdir -p "/Users/runner/Library/MobileDevice/Provisioning Profiles" # Copy with the UUID as filename UUID_TARGET_PATH="/Users/runner/Library/MobileDevice/Provisioning Profiles/${PROFILE_UUID}.mobileprovision" cp -v "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" "$UUID_TARGET_PATH" # Set correct permissions on the profile chmod 644 "$UUID_TARGET_PATH" chown runner:staff "$UUID_TARGET_PATH" # Save the profile path and name to environment for later steps echo "IOS_PROV_PROFILE_PATH=$UUID_TARGET_PATH" >> $GITHUB_ENV echo "IOS_PROV_PROFILE_NAME=$PROFILE_NAME" >> $GITHUB_ENV # Print provisioning profile information echo "Provisioning Profile Information:" echo "--------------------------------" echo "Profile Name (from file): $PROFILE_NAME" echo "Profile Name (from env): $IOS_PROV_PROFILE_NAME" echo "Profile Path: $UUID_TARGET_PATH" echo "Profile UUID: $PROFILE_UUID" echo "App Identifier: $IOS_APP_IDENTIFIER" echo "Team ID: $IOS_TEAM_ID" echo "--------------------------------" # List all provisioning profiles in the system with detailed info echo "List of all provisioning profiles in system:" ls -la "/Users/runner/Library/MobileDevice/Provisioning Profiles/" # Clean up temp file rm -f "$TEMP_PLIST_PATH" echo "โœ… Provisioning profile installation steps completed." - name: Build Dependencies (iOS) if: inputs.platform != 'android' run: | echo "๐Ÿ—๏ธ Building SDK dependencies..." cd ${{ env.APP_PATH }} yarn workspace @selfxyz/mobile-app run build:deps --silent || { echo "โŒ Dependency build failed"; exit 1; } echo "โœ… Dependencies built successfully" # act won't work with macos, but you can test with `bundle exec fastlane ios ...` - name: Build and upload to App Store Connect/TestFlight if: inputs.platform != 'android' && !env.ACT env: IOS_APP_IDENTIFIER: ${{ secrets.IOS_APP_IDENTIFIER }} IOS_CONNECT_API_KEY_BASE64: ${{ secrets.IOS_CONNECT_API_KEY_BASE64 }} IOS_CONNECT_API_KEY_PATH: ${{ env.APP_PATH }}${{ env.IOS_CONNECT_API_KEY_PATH }} IOS_CONNECT_ISSUER_ID: ${{ secrets.IOS_CONNECT_ISSUER_ID }} IOS_CONNECT_KEY_ID: ${{ secrets.IOS_CONNECT_KEY_ID }} IOS_P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }} IOS_PROJECT_NAME: ${{ secrets.IOS_PROJECT_NAME }} IOS_PROJECT_SCHEME: ${{ secrets.IOS_PROJECT_SCHEME }} IOS_PROV_PROFILE_DIR: ${{ env.IOS_PROV_PROFILE_DIRECTORY }} IOS_PROV_PROFILE_NAME: ${{ secrets.IOS_PROV_PROFILE_NAME }} IOS_PROV_PROFILE_PATH: ${{ env.IOS_PROV_PROFILE_PATH }} IOS_SIGNING_CERTIFICATE: ${{ secrets.IOS_SIGNING_CERTIFICATE }} IOS_TESTFLIGHT_GROUPS: ${{ secrets.IOS_TESTFLIGHT_GROUPS }} IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }} IOS_TEAM_NAME: ${{ secrets.IOS_TEAM_NAME }} NODE_OPTIONS: "--max-old-space-size=8192" SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }} SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} SLACK_ANNOUNCE_CHANNEL_NAME: ${{ secrets.SLACK_ANNOUNCE_CHANNEL_NAME }} timeout-minutes: 90 run: | cd ${{ env.APP_PATH }} echo "--- Pre-Fastlane Diagnostics ---" echo "Running as user: $(whoami)" echo "Default keychain:" security list-keychains -d user echo "Identities in build.keychain:" security find-identity -v -p codesigning build.keychain || echo "Failed to find identities in build.keychain" echo "--- Starting Fastlane ---" # Determine deployment track and version bump DEPLOYMENT_TRACK="${{ inputs.deployment_track || 'internal' }}" VERSION_BUMP="${{ inputs.version_bump || 'build' }}" TEST_MODE="${{ inputs.test_mode || false }}" echo "๐Ÿ“ฑ Deployment Configuration:" echo " - Track: $DEPLOYMENT_TRACK" echo " - Version Bump: $VERSION_BUMP" echo " - Test Mode: $TEST_MODE" if [ "$TEST_MODE" = "true" ]; then echo "๐Ÿงช Running in TEST MODE - will skip upload to TestFlight" bundle exec fastlane ios deploy_auto \ deployment_track:$DEPLOYMENT_TRACK \ version_bump:$VERSION_BUMP \ test_mode:true \ --verbose else echo "๐Ÿš€ Deploying to App Store Connect..." bundle exec fastlane ios deploy_auto \ deployment_track:$DEPLOYMENT_TRACK \ version_bump:$VERSION_BUMP \ --verbose fi # Version updates moved to separate job to avoid race conditions - name: Remove project.pbxproj updates we don't want to commit if: inputs.platform != 'android' run: | PBXPROJ_FILE="app/ios/Self.xcodeproj/project.pbxproj" # Create a temporary file to store version info echo "Extracting version information..." rm -f versions.txt grep -E 'CURRENT_PROJECT_VERSION = [0-9]+;|MARKETING_VERSION = [0-9]+\.[0-9]+\.[0-9]+;' "${PBXPROJ_FILE}" > versions.txt # Check if we have version information if [ -s versions.txt ]; then echo "Found version information. Resetting file and re-applying versions..." # Store the version values CURRENT_VERSION=$(grep 'CURRENT_PROJECT_VERSION' versions.txt | head -1 | sed 's/.*CURRENT_PROJECT_VERSION = \([0-9]*\);.*/\1/') MARKETING_VERSION=$(grep 'MARKETING_VERSION' versions.txt | head -1 | sed 's/.*MARKETING_VERSION = \([0-9]*\.[0-9]*\.[0-9]*\);.*/\1/') echo "Current version: $CURRENT_VERSION" echo "Marketing version: $MARKETING_VERSION" # Reset the file to HEAD git checkout HEAD -- "${PBXPROJ_FILE}" # Update the versions if they exist if [ ! -z "$CURRENT_VERSION" ]; then sed -i '' "s/\(CURRENT_PROJECT_VERSION = \)[0-9]*;/\1$CURRENT_VERSION;/g" "${PBXPROJ_FILE}" fi if [ ! -z "$MARKETING_VERSION" ]; then sed -i '' "s/\(MARKETING_VERSION = \)[0-9]*\.[0-9]*\.[0-9]*;/\1$MARKETING_VERSION;/g" "${PBXPROJ_FILE}" fi echo "Version information successfully applied." else echo "No version information found. Resetting file..." git checkout HEAD -- "${PBXPROJ_FILE}" fi # Clean up rm -f versions.txt - name: Get version from package.json if: inputs.platform != 'android' uses: ./.github/actions/get-version with: app_path: ${{ env.APP_PATH }} - name: Open PR for iOS build number bump if: ${{ !env.ACT && success() }} uses: peter-evans/create-pull-request@v6 with: title: "chore: bump iOS build for ${{ env.VERSION }}" body: "Automated bump of iOS build number by CI" commit-message: "chore: incrementing ios build number for version ${{ env.VERSION }} [github action]" branch: ci/bump-ios-build-${{ github.run_id }} base: staging add-paths: | app/version.json - name: Monitor cache usage if: always() run: | echo "๐Ÿ“Š Cache Size Report (iOS Build)" echo "================================" if [ -d "${{ env.APP_PATH }}/node_modules" ]; then NODE_SIZE=$(du -sh "${{ env.APP_PATH }}/node_modules" | cut -f1) echo "Node modules: $NODE_SIZE" fi if [ -d "${{ env.APP_PATH }}/ios/vendor/bundle" ]; then GEMS_SIZE=$(du -sh "${{ env.APP_PATH }}/ios/vendor/bundle" | cut -f1) echo "Ruby gems: $GEMS_SIZE" fi if [ -d "${{ env.APP_PATH }}/ios/Pods" ]; then PODS_SIZE=$(du -sh "${{ env.APP_PATH }}/ios/Pods" | cut -f1) echo "CocoaPods: $PODS_SIZE" fi echo "================================" echo "๐Ÿ’ก GitHub Actions cache limit: 10GB per repository" build-android: runs-on: ubuntu-latest if: (inputs.platform == 'android' || inputs.platform == 'both') steps: - name: Mobile deployment status run: | echo "๐Ÿš€ Mobile deployment is enabled - proceeding with Android build" echo "๐Ÿ“ฑ Platform: ${{ inputs.platform }}" echo "๐ŸŽฏ Track: ${{ inputs.deployment_track }}" echo "๐Ÿ“ฆ Version bump: ${{ inputs.version_bump }}" - uses: actions/checkout@v4 if: inputs.platform != 'ios' with: fetch-depth: 0 ref: staging - uses: "google-github-actions/auth@v2" with: project_id: "plucky-tempo-454713-r0" workload_identity_provider: "projects/852920390127/locations/global/workloadIdentityPools/gh-self/providers/github-by-repos" service_account: "self-xyz@plucky-tempo-454713-r0.iam.gserviceaccount.com" # Fail fast: set up JDK for keytool and verify Android secrets early - name: Setup Java environment if: inputs.platform != 'ios' uses: actions/setup-java@v4 with: distribution: "temurin" java-version: ${{ env.JAVA_VERSION }} - name: Decode Android Secrets if: inputs.platform != 'ios' run: | echo "${{ secrets.ANDROID_KEYSTORE }}" | base64 --decode > ${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }} - name: Verify Android Secrets if: inputs.platform != 'ios' run: | # Verify Google Cloud auth via Workload Identity Federation (ADC) if [ -z "$GOOGLE_APPLICATION_CREDENTIALS" ] || [ ! -f "$GOOGLE_APPLICATION_CREDENTIALS" ]; then echo "โŒ Error: GOOGLE_APPLICATION_CREDENTIALS not set or file missing. Ensure google-github-actions/auth ran." exit 1 fi # Verify keystore file exists and is valid if [ ! -f "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" ]; then echo "โŒ Error: Keystore file was not created successfully" exit 1 fi # Try to verify the keystore with the provided password if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >/dev/null 2>&1; then echo "โŒ Error: Invalid keystore password" exit 1 fi # Verify the key alias exists if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" -alias "${{ secrets.ANDROID_KEY_ALIAS }}" >/dev/null 2>&1; then echo "โŒ Error: Key alias '${{ secrets.ANDROID_KEY_ALIAS }}' not found in keystore" exit 1 fi # Verify the key password if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" -alias "${{ secrets.ANDROID_KEY_ALIAS }}" -keypass "${{ secrets.ANDROID_KEY_PASSWORD }}" >/dev/null 2>&1; then echo "โŒ Error: Invalid key password" exit 1 fi # Detect keystore type and export for later steps KEYSTORE_TYPE=$(keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" 2>/dev/null | awk -F': ' '/Keystore type:/ {print $2; exit}') if [ -z "$KEYSTORE_TYPE" ]; then echo "โŒ Error: Unable to determine keystore type" exit 1 fi echo "ANDROID_KEYSTORE_TYPE=$KEYSTORE_TYPE" >> "$GITHUB_ENV" echo "Detected keystore type: $KEYSTORE_TYPE" # Ensure the alias holds a PrivateKeyEntry (required for signing) if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" -alias "${{ secrets.ANDROID_KEY_ALIAS }}" -keypass "${{ secrets.ANDROID_KEY_PASSWORD }}" | grep -q "Entry type: PrivateKeyEntry"; then echo "โŒ Error: Alias '${{ secrets.ANDROID_KEY_ALIAS }}' is not a PrivateKeyEntry" exit 1 fi echo "โœ… All Android secrets verified successfully!" - name: Read and sanitize Node.js version shell: bash run: | if [ ! -f .nvmrc ] || [ -z "$(cat .nvmrc)" ]; then echo "โŒ .nvmrc is missing or empty"; exit 1; fi VERSION="$(tr -d '\r\n' < .nvmrc)" VERSION="${VERSION#v}" if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then echo "Invalid .nvmrc content: '$VERSION'"; exit 1; fi echo "NODE_VERSION=$VERSION" >> "$GITHUB_ENV" echo "NODE_VERSION_SANITIZED=${VERSION//\//-}" >> "$GITHUB_ENV" - name: Verify branch and commit (Android) if: inputs.platform != 'ios' run: | echo "๐Ÿ” Verifying we're building from the correct branch and commit..." echo "Current branch: $(git branch --show-current || git symbolic-ref --short HEAD 2>/dev/null || echo 'detached')" echo "Current commit: $(git rev-parse HEAD)" echo "Current commit message: $(git log -1 --pretty=format:'%s')" echo "Staging HEAD commit: $(git rev-parse origin/staging)" echo "Staging HEAD message: $(git log -1 --pretty=format:'%s' origin/staging)" if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/staging)" ]; then echo "โš ๏ธ WARNING: Current commit differs from latest staging commit" echo "This might indicate we're not building from the latest staging branch" git log --oneline HEAD..origin/staging || true else echo "โœ… Building from latest staging commit" fi - name: Cache Yarn artifacts id: yarn-cache uses: ./.github/actions/cache-yarn with: path: | .yarn/cache .yarn/install-state.gz .yarn/unplugged cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }} - name: Cache Ruby gems id: gems-cache uses: ./.github/actions/cache-bundler with: path: ${{ env.APP_PATH }}/ios/vendor/bundle key: ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-${{ hashFiles('app/Gemfile.lock') }} restore-keys: | ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-${{ hashFiles('app/Gemfile.lock') }}- ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}- ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}- - name: Cache Gradle dependencies id: gradle-cache uses: ./.github/actions/cache-gradle with: cache-version: ${{ env.GH_CACHE_VERSION }}-${{ env.GH_GRADLE_CACHE_VERSION }} - name: Cache Android NDK id: ndk-cache uses: actions/cache@v4 with: path: ${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.ANDROID_NDK_VERSION }} key: ${{ runner.os }}-ndk-${{ env.ANDROID_NDK_VERSION }} - name: Log cache status run: | echo "Cache hit results:" echo "- Yarn cache hit: ${{ steps.yarn-cache.outputs.cache-hit }}" echo "- Gems cache hit: ${{ steps.gems-cache.outputs.cache-hit }}" echo "- Gradle cache hit: ${{ steps.gradle-cache.outputs.cache-hit }}" echo "- NDK cache hit: ${{ steps.ndk-cache.outputs.cache-hit }}" - name: Disable Yarn hardened mode run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV - name: Verify lock files are up to date run: | echo "๐Ÿ” Checking if lock files are in sync with dependency files..." # For yarn workspaces, yarn.lock is at root if [ ! -f "${{ env.WORKSPACE }}/yarn.lock" ]; then echo "โŒ ERROR: yarn.lock file is missing at workspace root!" echo "Run 'yarn install' at the repository root and commit the yarn.lock file." exit 1 fi # Gemfile.lock is in app directory if [ ! -f "${{ env.APP_PATH }}/Gemfile.lock" ]; then echo "โŒ ERROR: Gemfile.lock file is missing!" echo "Run 'bundle install' in the app directory and commit the Gemfile.lock file." exit 1 fi echo "โœ… Lock files exist" - name: Install Mobile Dependencies if: inputs.platform != 'ios' uses: ./.github/actions/mobile-setup with: app_path: ${{ env.APP_PATH }} node_version: ${{ env.NODE_VERSION }} ruby_version: ${{ env.RUBY_VERSION }} workspace: ${{ env.WORKSPACE }} env: SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }} PLATFORM: ${{ inputs.platform }} # android specific steps - name: Setup Android SDK if: inputs.platform != 'ios' uses: android-actions/setup-android@v3 with: accept-android-sdk-licenses: true - name: Install NDK and CMake if: inputs.platform != 'ios' && steps.ndk-cache.outputs.cache-hit != 'true' run: | max_attempts=5 attempt=1 # Install NDK while [ $attempt -le $max_attempts ]; do echo "Attempt $attempt of $max_attempts to install NDK..." if sdkmanager "ndk;${{ env.ANDROID_NDK_VERSION }}"; then echo "Successfully installed NDK" break fi echo "Failed to install NDK on attempt $attempt" if [ $attempt -eq $max_attempts ]; then echo "All attempts to install NDK failed" exit 1 fi # Exponential backoff: 2^attempt seconds wait_time=$((2 ** attempt)) echo "Waiting $wait_time seconds before retrying..." sleep $wait_time attempt=$((attempt + 1)) done # Install CMake (required for native module builds) echo "Installing CMake..." attempt=1 while [ $attempt -le $max_attempts ]; do echo "Attempt $attempt of $max_attempts to install CMake..." if sdkmanager "cmake;3.22.1"; then echo "Successfully installed CMake" break fi echo "Failed to install CMake on attempt $attempt" if [ $attempt -eq $max_attempts ]; then echo "All attempts to install CMake failed" exit 1 fi # Exponential backoff: 2^attempt seconds wait_time=$((2 ** attempt)) echo "Waiting $wait_time seconds before retrying..." sleep $wait_time attempt=$((attempt + 1)) done - name: Set Gradle JVM options if: inputs.platform != 'ios' # Apply to CI builds (not just ACT) run: | echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8" >> ${{ env.APP_PATH }}/android/gradle.properties - name: Install Python dependencies for Play Store upload if: inputs.platform != 'ios' run: | python -m pip install --upgrade pip pip install google-auth google-auth-oauthlib google-auth-httplib2 google-api-python-client - name: Clone android-passport-reader if: inputs.platform != 'ios' uses: ./.github/actions/clone-android-passport-reader with: working_directory: ${{ env.APP_PATH }} selfxyz_internal_pat: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }} - name: Build Dependencies (Android) if: inputs.platform != 'ios' run: | echo "๐Ÿ—๏ธ Building SDK dependencies..." cd ${{ env.APP_PATH }} yarn workspace @selfxyz/mobile-app run build:deps --silent || { echo "โŒ Dependency build failed"; exit 1; } echo "โœ… Dependencies built successfully" - name: Build AAB with Fastlane if: inputs.platform != 'ios' env: ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEYSTORE_PATH: ${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} ANDROID_PACKAGE_NAME: ${{ secrets.ANDROID_PACKAGE_NAME }} NODE_OPTIONS: "--max-old-space-size=6144" run: | cd ${{ env.APP_PATH }} # Determine deployment track and version bump DEPLOYMENT_TRACK="${{ inputs.deployment_track || 'internal' }}" VERSION_BUMP="${{ inputs.version_bump || 'build' }}" TEST_MODE="${{ inputs.test_mode || false }}" echo "๐Ÿค– Build Configuration:" echo " - Track: $DEPLOYMENT_TRACK" echo " - Version Bump: $VERSION_BUMP" echo " - Test Mode: $TEST_MODE" echo "๐Ÿ”จ Building AAB with Fastlane..." bundle exec fastlane android build_only \ deployment_track:$DEPLOYMENT_TRACK \ version_bump:$VERSION_BUMP \ --verbose - name: Upload to Google Play Store using WIF if: inputs.platform != 'ios' && inputs.test_mode != true env: SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }} SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} SLACK_ANNOUNCE_CHANNEL_NAME: ${{ secrets.SLACK_ANNOUNCE_CHANNEL_NAME }} run: | cd ${{ env.APP_PATH }} # Determine deployment track DEPLOYMENT_TRACK="${{ inputs.deployment_track || 'internal' }}" echo "๐Ÿš€ Uploading to Google Play Store using Workload Identity Federation..." python scripts/upload_to_play_store.py \ --aab "android/app/build/outputs/bundle/release/app-release.aab" \ --package-name "${{ secrets.ANDROID_PACKAGE_NAME }}" \ --track "$DEPLOYMENT_TRACK" # Version updates moved to separate job to avoid race conditions - name: Get version from package.json if: inputs.platform != 'ios' uses: ./.github/actions/get-version with: app_path: ${{ env.APP_PATH }} - name: Open PR for Android build number bump if: ${{ !env.ACT && success() }} uses: peter-evans/create-pull-request@v6 with: title: "chore: bump Android build for ${{ env.VERSION }}" body: "Automated bump of Android build number by CI" commit-message: "chore: incrementing android build version for version ${{ env.VERSION }} [github action]" branch: ci/bump-android-build-${{ github.run_id }} base: staging add-paths: | app/version.json - name: Monitor cache usage if: always() run: | echo "๐Ÿ“Š Cache Size Report (Android Build)" echo "====================================" if [ -d "${{ env.APP_PATH }}/node_modules" ]; then NODE_SIZE=$(du -sh "${{ env.APP_PATH }}/node_modules" | cut -f1) echo "Node modules: $NODE_SIZE" fi if [ -d "${{ env.APP_PATH }}/ios/vendor/bundle" ]; then GEMS_SIZE=$(du -sh "${{ env.APP_PATH }}/ios/vendor/bundle" | cut -f1) echo "Ruby gems: $GEMS_SIZE" fi if [ -d "$HOME/.gradle/caches" ]; then GRADLE_SIZE=$(du -sh "$HOME/.gradle/caches" | cut -f1) echo "Gradle caches: $GRADLE_SIZE" fi if [ -d "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.ANDROID_NDK_VERSION }}" ]; then NDK_SIZE=$(du -sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.ANDROID_NDK_VERSION }}" | cut -f1) echo "Android NDK: $NDK_SIZE" fi echo "====================================" echo "๐Ÿ’ก GitHub Actions cache limit: 10GB per repository" # Separate job to update version files after successful deployment # This avoids race conditions when both iOS and Android run in parallel update-version: runs-on: ubuntu-latest needs: [build-ios, build-android] if: | always() && inputs.test_mode != true && (needs.build-ios.result == 'success' || needs.build-android.result == 'success') env: APP_PATH: ${{ github.workspace }}/app steps: - uses: actions/checkout@v4 with: token: ${{ github.token }} fetch-depth: 0 ref: staging - name: Read and sanitize Node.js version shell: bash run: | if [ ! -f .nvmrc ] || [ -z "$(cat .nvmrc)" ]; then echo "โŒ .nvmrc is missing or empty"; exit 1; fi VERSION="$(tr -d '\r\n' < .nvmrc)" VERSION="${VERSION#v}" if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then echo "Invalid .nvmrc content: '$VERSION'"; exit 1; fi echo "NODE_VERSION=$VERSION" >> "$GITHUB_ENV" echo "NODE_VERSION_SANITIZED=${VERSION//\//-}" >> "$GITHUB_ENV" - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} - name: Update package.json version run: | cd ${{ env.APP_PATH }} # Get current version from package.json CURRENT_VERSION=$(node -p "require('./package.json').version") # Get new version from version.json (if it exists and has version field) if [ -f version.json ] && grep -q '"version"' version.json; then NEW_VERSION=$(node -pe 'require("./version.json").version' 2>/dev/null || echo "") else # Fallback: use current version from package.json NEW_VERSION="$CURRENT_VERSION" fi # Only update if versions differ if [ "$CURRENT_VERSION" != "$NEW_VERSION" ] && [ -n "$NEW_VERSION" ]; then echo "๐Ÿ“ฆ Updating package.json version:" echo " From: v$CURRENT_VERSION" echo " To: v$NEW_VERSION" # Use yarn to update package.json and the lockfile yarn version --new-version "$NEW_VERSION" --no-git-tag-version -y else echo "โ„น๏ธ Version already up to date or no version field in version.json" fi - name: Open PR to update version files uses: peter-evans/create-pull-request@v6 with: title: "chore: update version files after deployment" body: | Automated update of version files after successful deployment. Includes updates to `app/version.json`, `app/package.json`, and `yarn.lock`. commit-message: "chore: update version files after deployment [skip ci]" branch: ci/update-version-${{ github.run_id }} base: staging add-paths: | app/version.json app/package.json yarn.lock # Create git tags after successful deployment create-release-tags: needs: [build-ios, build-android, update-version] if: | always() && needs.update-version.result == 'success' && (needs.build-ios.result == 'success' || needs.build-android.result == 'success') && inputs.deployment_track == 'production' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 ref: staging token: ${{ secrets.GITHUB_TOKEN }} - name: Configure Git run: | git config --global user.name "GitHub Actions" git config --global user.email "actions@github.com" - name: Create and push tags run: | cd ${{ env.APP_PATH }} # Read current version info VERSION=$(cat package.json | jq -r .version) IOS_BUILD=$(cat version.json | jq -r .ios.build) ANDROID_BUILD=$(cat version.json | jq -r .android.build) echo "๐Ÿ“ฆ Creating tags for version $VERSION" # Create main version tag if ! git tag -l | grep -q "^v${VERSION}$"; then git tag -a "v${VERSION}" -m "Release ${VERSION}" echo "โœ… Created tag: v${VERSION}" else echo "โญ๏ธ Tag v${VERSION} already exists" fi # Create platform-specific tags if deployments succeeded if [ "${{ needs.build-ios.result }}" = "success" ]; then TAG_NAME="v${VERSION}-ios-${IOS_BUILD}" if ! git tag -l | grep -q "^${TAG_NAME}$"; then git tag -a "${TAG_NAME}" -m "iOS Release ${VERSION} (Build ${IOS_BUILD})" echo "โœ… Created tag: ${TAG_NAME}" fi fi if [ "${{ needs.build-android.result }}" = "success" ]; then TAG_NAME="v${VERSION}-android-${ANDROID_BUILD}" if ! git tag -l | grep -q "^${TAG_NAME}$"; then git tag -a "${TAG_NAME}" -m "Android Release ${VERSION} (Build ${ANDROID_BUILD})" echo "โœ… Created tag: ${TAG_NAME}" fi fi # Push all tags git push origin --tags echo "๐Ÿš€ Tags pushed to repository" - name: Generate changelog for release id: changelog run: | cd ${{ env.APP_PATH }} # Find the previous version tag PREV_TAG=$(git tag -l "v*" | grep -v "-" | sort -V | tail -2 | head -1 || echo "") # Generate simple changelog echo "## What's Changed" > release_notes.md echo "" >> release_notes.md if [ -n "$PREV_TAG" ]; then git log --pretty=format:"- %s" ${PREV_TAG}..HEAD --no-merges | grep -v "^- Merge" >> release_notes.md else echo "Initial release" >> release_notes.md fi echo "" >> release_notes.md echo "## Build Information" >> release_notes.md echo "- iOS Build: ${IOS_BUILD}" >> release_notes.md echo "- Android Build: ${ANDROID_BUILD}" >> release_notes.md # Set output for next step echo "version=${VERSION}" >> $GITHUB_OUTPUT - name: Create GitHub Release uses: softprops/action-gh-release@v1 with: tag_name: v${{ steps.changelog.outputs.version }} name: Release ${{ steps.changelog.outputs.version }} body_path: ${{ env.APP_PATH }}/release_notes.md draft: false prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}