From b841b19d96299979afa2eb13e2aef255e37378f4 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Thu, 3 Jul 2025 22:32:14 -0700 Subject: [PATCH] Improve manual mobile deploy workflow and docs (#728) * Add basic Fastlane helper tests * Upgrade fastlane and enhance helper tests (#738) * simplify mobile deploy pipelines and make them manual. update readme * update fastlane dev readme * update tests and add helper script * cr feedback, update tests, revert circuits package.json sort change * tweaks * fix slack * cr feedback and fixes * add better cjs eslint support * save wip. add confirmation check script. update scripts * remove auto increment feature * migrate readme items over to DEV due to fastlane auto regen docs flow * use regular xcode * fix hermes compiler path * coderabbit feedback * reinstall when on local dev * fix upload * simplify * simplify confirmation feedback with tests * fix mobile deploys * cr feedback * test iOS building * fix trigger logic * cr feedback * updates * fix env var * fix order * re-enable upload to testflight for ios * updated notes --- .github/actions/get-version/action.yml | 2 +- .github/actions/mobile-setup/action.yml | 2 +- .github/workflows/mobile-deploy.yml | 113 +-- app/.eslintrc.cjs | 11 + app/Gemfile | 2 +- app/Gemfile.lock | 4 +- app/android/app/build.gradle | 30 +- app/fastlane/.env.secrets.example | 2 +- app/fastlane/DEV.md | 530 ++++++++++--- app/fastlane/Fastfile | 51 +- app/fastlane/README.md | 8 - app/fastlane/helpers.rb | 736 +----------------- app/fastlane/helpers/android.rb | 90 +++ app/fastlane/helpers/common.rb | 89 +++ app/fastlane/helpers/ios.rb | 148 ++++ app/fastlane/helpers/slack.rb | 102 +++ app/fastlane/test/helpers_test.rb | 342 ++++++++ app/ios/Self.xcodeproj/project.pbxproj | 4 +- app/package.json | 20 +- app/scripts/mobile-deploy-confirm.cjs | 542 +++++++++++++ .../tests/mobile-deploy-confirm.test.cjs | 314 ++++++++ package.json | 2 +- 22 files changed, 2195 insertions(+), 949 deletions(-) create mode 100644 app/fastlane/helpers/android.rb create mode 100644 app/fastlane/helpers/common.rb create mode 100644 app/fastlane/helpers/ios.rb create mode 100644 app/fastlane/helpers/slack.rb create mode 100644 app/fastlane/test/helpers_test.rb create mode 100755 app/scripts/mobile-deploy-confirm.cjs create mode 100644 app/scripts/tests/mobile-deploy-confirm.test.cjs diff --git a/.github/actions/get-version/action.yml b/.github/actions/get-version/action.yml index 7cba6b139..cff46bfcd 100644 --- a/.github/actions/get-version/action.yml +++ b/.github/actions/get-version/action.yml @@ -14,4 +14,4 @@ runs: shell: bash run: | VERSION=$(node -p "require('${{ inputs.app_path }}/package.json').version") - echo "VERSION=$VERSION" >> $GITHUB_ENV \ No newline at end of file + echo "VERSION=$VERSION" >> $GITHUB_ENV diff --git a/.github/actions/mobile-setup/action.yml b/.github/actions/mobile-setup/action.yml index 291c0598f..4fb15a340 100644 --- a/.github/actions/mobile-setup/action.yml +++ b/.github/actions/mobile-setup/action.yml @@ -54,4 +54,4 @@ runs: corepack enable yarn set version 4.6.0 yarn install - yarn install-app:deploy + yarn install-app:mobile-deploy diff --git a/.github/workflows/mobile-deploy.yml b/.github/workflows/mobile-deploy.yml index 14bfbc418..ba78e37a4 100644 --- a/.github/workflows/mobile-deploy.yml +++ b/.github/workflows/mobile-deploy.yml @@ -1,11 +1,6 @@ name: Mobile App Deployments env: - # Branch configuration - IS_PR: ${{ github.event.pull_request.number != null }} - STAGING_BRANCH: dev - MAIN_BRANCH: main - # Build environment versions NODE_VERSION: 18 RUBY_VERSION: 3.2 @@ -30,32 +25,45 @@ permissions: pull-requests: write on: - push: - branches: - - dev - - main - paths: - - "app/**" - - ".github/workflows/mobile-deploy.yml" - pull_request: - paths: - - "app/**" - - ".github/workflows/mobile-deploy.yml" + workflow_dispatch: + inputs: + platform: + description: "Select platform to build" + required: true + default: "both" + type: choice + options: + - ios + - android + - both jobs: build-ios: - # disable for now, will fix soon - if: false runs-on: macos-latest + if: github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'ios' || github.event.inputs.platform == 'both') steps: + - name: Mobile deployment status + run: | + if [ "${{ github.event_name }}" != "workflow_dispatch" ]; then + echo "๐Ÿ“ฑ Mobile deployment is disabled for pull requests" + echo "๐Ÿš€ To deploy, use the workflow_dispatch trigger (Run workflow button)" + echo "โœ… Deployment steps will be skipped for this PR" + else + echo "๐Ÿš€ Mobile deployment is enabled - proceeding with iOS build" + fi + - name: Set up Xcode + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' uses: maxim-lobanov/setup-xcode@v1 - with: - # some cocoapods won't compile with xcode 16.3 - xcode-version: "16.2" + # with: + # # some cocoapods won't compile with xcode 16.3 + # xcode-version: "16.2" - uses: actions/checkout@v4 + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' + - name: Install Mobile Dependencies + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' uses: ./.github/actions/mobile-setup with: app_path: ${{ env.APP_PATH }} @@ -64,6 +72,7 @@ jobs: workspace: ${{ env.WORKSPACE }} - name: Verify iOS Secrets + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' run: | # Verify App Store Connect API Key exists and contains PEM header if [ -z "${{ secrets.IOS_CONNECT_API_KEY_BASE64 }}" ]; then @@ -97,6 +106,7 @@ jobs: echo "โœ… All iOS secrets verified successfully!" - name: Decode certificate and profile + if: github.event_name == 'workflow_dispatch' && github.event.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 }} @@ -128,7 +138,7 @@ jobs: fi - name: Verify iOS certificate and environment - if: ${{ !env.ACT }} + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' && !env.ACT run: | # Check if certificate directory exists if [ ! -d "${{ env.APP_PATH }}/ios/certs" ]; then @@ -168,7 +178,7 @@ jobs: echo "โœ… Certificate and environment verification passed!" - name: Install certificate - if: ${{ !env.ACT }} + if: github.event_name == 'workflow_dispatch' && github.event.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 @@ -177,7 +187,7 @@ jobs: security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain >/dev/null 2>&1 - name: Install provisioning profile - if: ${{ !env.ACT }} + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' && !env.ACT env: IOS_APP_IDENTIFIER: ${{ secrets.IOS_APP_IDENTIFIER }} IOS_PROV_PROFILE_NAME: ${{ secrets.IOS_PROV_PROFILE_NAME }} @@ -268,10 +278,9 @@ jobs: echo "โœ… Provisioning profile installation steps completed." # act won't work with macos, but you can test with `bundle exec fastlane ios ...` - - name: Build and upload to TestFlight (Internal) - if: ${{ !env.ACT }} + - name: Build and upload to App Store Connect/TestFlight + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' && !env.ACT env: - IS_PR: ${{ env.IS_PR }} 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 }} @@ -289,6 +298,7 @@ jobs: 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: | @@ -300,15 +310,11 @@ jobs: echo "Identities in build.keychain:" security find-identity -v -p codesigning build.keychain || echo "Failed to find identities in build.keychain" echo "--- Starting Fastlane ---" - # if pushing to main, deploy to App Store - if [ "${{ github.ref }}" = "refs/heads/${{ env.MAIN_BRANCH }}" ]; then - bundle exec fastlane ios deploy --verbose - # else to upload to TestFlight Internal Testing - else - bundle exec fastlane ios internal_test --verbose - fi + echo "๐Ÿš€ Uploading to App Store Connect/TestFlight..." + bundle exec fastlane ios internal_test --verbose - name: Remove project.pbxproj updates we don't want to commit + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' run: | PBXPROJ_FILE="app/ios/Self.xcodeproj/project.pbxproj" @@ -350,6 +356,7 @@ jobs: rm -f versions.txt - name: Get version from package.json + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' uses: ./.github/actions/get-version with: app_path: ${{ env.APP_PATH }} @@ -363,12 +370,24 @@ jobs: commit_paths: "./app/ios/OpenPassport/Info.plist ./app/ios/Self.xcodeproj/project.pbxproj" build-android: - # disable for now, will fix soon - if: false runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'android' || github.event.inputs.platform == 'both') steps: + - name: Mobile deployment status + run: | + if [ "${{ github.event_name }}" != "workflow_dispatch" ]; then + echo "๐Ÿ“ฑ Mobile deployment is disabled for pull requests" + echo "๐Ÿš€ To deploy, use the workflow_dispatch trigger (Run workflow button)" + echo "โœ… Deployment steps will be skipped for this PR" + else + echo "๐Ÿš€ Mobile deployment is enabled - proceeding with Android build" + fi + - uses: actions/checkout@v4 + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' + - name: Install Mobile Dependencies + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' uses: ./.github/actions/mobile-setup with: app_path: ${{ env.APP_PATH }} @@ -378,17 +397,20 @@ jobs: # android specific steps - name: Setup Java environment - uses: actions/setup-java@v3 + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' + uses: actions/setup-java@v4 with: distribution: "temurin" java-version: ${{ env.JAVA_VERSION }} - name: Setup Android SDK + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' uses: android-actions/setup-android@v3 with: accept-android-sdk-licenses: true - name: Install NDK + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' run: | max_attempts=5 attempt=1 @@ -411,17 +433,19 @@ jobs: done - name: Set Gradle JVM options - if: ${{ env.ACT }} # run when testing locally with act to prevent gradle crashes + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' && env.ACT run: | echo "org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8" >> ${{ env.APP_PATH }}/android/gradle.properties - name: Decode Android Secrets + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' run: | echo "${{ secrets.ANDROID_KEYSTORE }}" | base64 --decode > ${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }} echo "${{ secrets.ANDROID_PLAY_STORE_JSON_KEY_BASE64 }}" | base64 --decode > ${{ env.APP_PATH }}${{ env.ANDROID_PLAY_STORE_JSON_KEY_PATH }} # run secrets check after keytool has been setup - name: Verify Android Secrets + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' run: | # Verify Play Store JSON key base64 secret exists and is valid if [ -z "${{ secrets.ANDROID_PLAY_STORE_JSON_KEY_BASE64 }}" ]; then @@ -456,8 +480,8 @@ jobs: echo "โœ… All Android secrets verified successfully!" - name: Build and upload to Google Play Internal Testing + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' env: - IS_PR: ${{ env.IS_PR }} ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEYSTORE_PATH: ${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }} @@ -467,18 +491,15 @@ jobs: ANDROID_PLAY_STORE_JSON_KEY_PATH: ${{ env.APP_PATH }}${{ env.ANDROID_PLAY_STORE_JSON_KEY_PATH }} 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 }} run: | cd ${{ env.APP_PATH }} - # if pushing to main, deploy to Play Store - if [ "${{ github.ref }}" = "refs/heads/${{ env.MAIN_BRANCH }}" ]; then - bundle exec fastlane android deploy --verbose - # else to upload to Play Store Internal Testing - else - bundle exec fastlane android internal_test --verbose - fi + echo "๐Ÿš€ Uploading to Google Play Internal Testing..." + bundle exec fastlane android internal_test --verbose - name: Get version from package.json + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' uses: ./.github/actions/get-version with: app_path: ${{ env.APP_PATH }} diff --git a/app/.eslintrc.cjs b/app/.eslintrc.cjs index 05212e598..77e949e2e 100644 --- a/app/.eslintrc.cjs +++ b/app/.eslintrc.cjs @@ -45,8 +45,19 @@ module.exports = { overrides: [ { files: ['*.cjs'], + env: { + node: true, + commonjs: true, + es6: true, + }, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'script', + }, rules: { 'header/header': 'off', + '@typescript-eslint/no-var-requires': 'off', + 'no-undef': 'off', }, }, ], diff --git a/app/Gemfile b/app/Gemfile index e8bce84e1..c66194801 100644 --- a/app/Gemfile +++ b/app/Gemfile @@ -8,7 +8,7 @@ gem "cocoapods", ">= 1.13", "!= 1.15.0", "!= 1.15.1" gem "activesupport", ">= 6.1.7.5", "!= 7.1.0" # Add fastlane for CI/CD -gem "fastlane", "~> 2.227.0" +gem "fastlane", "~> 2.228.0" group :development do gem "dotenv" diff --git a/app/Gemfile.lock b/app/Gemfile.lock index 3e906bdc4..4197a637e 100644 --- a/app/Gemfile.lock +++ b/app/Gemfile.lock @@ -130,7 +130,7 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.227.1) + fastlane (2.228.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -305,7 +305,7 @@ DEPENDENCIES activesupport (>= 6.1.7.5, != 7.1.0) cocoapods (>= 1.13, != 1.15.1, != 1.15.0) dotenv - fastlane (~> 2.227.0) + fastlane (~> 2.228.0) fastlane-plugin-increment_version_code fastlane-plugin-versioning_android nokogiri (~> 1.18) diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index 327d88fc6..d6cfe7791 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -48,7 +48,35 @@ react { /* Hermes Commands */ // The hermes compiler command to run. By default it is 'hermesc' - hermesCommand = "../node_modules/react-native/sdks/hermesc/osx-bin/hermesc" + // Dynamic path that works across different platforms (macOS, Linux, Windows) + hermesCommand = { + def hermesPath = "../node_modules/react-native/sdks/hermesc" + def osName = System.getProperty('os.name').toLowerCase() + def platformDir + def executableName = "hermesc" + + if (osName.contains('mac')) { + platformDir = "osx-bin" + } else if (osName.contains('linux')) { + platformDir = "linux64-bin" + } else if (osName.contains('windows')) { + platformDir = "win64-bin" + executableName = "hermesc.exe" + } else { + // Fallback to trying common locations + platformDir = "linux64-bin" + } + + def dynamicPath = "${hermesPath}/${platformDir}/${executableName}" + + // Check if the dynamic path exists, otherwise fallback to just 'hermesc' + if (new File(dynamicPath).exists()) { + return dynamicPath + } else { + // Fallback to system PATH + return "hermesc" + } + }() // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" hermesFlags = ["-O", "-output-source-map"] diff --git a/app/fastlane/.env.secrets.example b/app/fastlane/.env.secrets.example index ef4932820..de9f5f948 100644 --- a/app/fastlane/.env.secrets.example +++ b/app/fastlane/.env.secrets.example @@ -19,4 +19,4 @@ IOS_TEAM_ID= IOS_TEAM_NAME= IOS_TESTFLIGHT_GROUPS= SLACK_CHANNEL_ID= -SLACK_BOT_TOKEN= +SLACK_API_TOKEN= diff --git a/app/fastlane/DEV.md b/app/fastlane/DEV.md index 16a9fc732..996e9bab0 100644 --- a/app/fastlane/DEV.md +++ b/app/fastlane/DEV.md @@ -2,26 +2,150 @@ This document outlines how to work with the Fastlane setup and the GitHub Actions CI/CD pipeline for this mobile application. +> **โš ๏ธ IMPORTANT - Manual Version Management Required** +> +> Build numbers are **manually managed** in this project. Before every deployment, you **must**: +> 1. Run `yarn bump-version:patch|minor|major` to increment the version +> 2. Run `yarn sync-versions` to update native files +> 3. Commit and push the changes +> +> **Deployments will fail** if version numbers are not manually incremented first. + ## Table of Contents -- [Prerequisites](#prerequisites-) -- [Setup](#setup-) -- [Workflow Overview](#workflow-overview-) -- [Local Development](#local-development-) -- [CI/CD Pipeline](#cicd-pipeline-) -- [Version Management](#version-management-) -- [Platform-Specific Notes](#platform-specific-notes-) -- [Troubleshooting](#troubleshooting-) -- [Additional Resources](#additional-resources-) +- [Quick Start](#quick-start) +- [Prerequisites](#prerequisites) +- [Setup](#setup) +- [Workflow Overview](#workflow-overview) +- [Local Development](#local-development) +- [CI/CD Pipeline](#cicd-pipeline) +- [Version Management](#manual-build-number-management) +- [Platform-Specific Notes](#platform-specific-notes) +- [Advanced Features](#advanced-features) +- [Troubleshooting](#troubleshooting) +- [Additional Resources](#additional-resources) + +## Quick Start ๐Ÿš€ + +**โš ๏ธ Important:** Before deploying, you must manually increment the build version: + +```sh +# 1. First, bump the version (choose one) +yarn bump-version:patch # For patch releases (1.0.0 โ†’ 1.0.1) +yarn bump-version:minor # For minor releases (1.0.0 โ†’ 1.1.0) +yarn bump-version:major # For major releases (1.0.0 โ†’ 2.0.0) + +# 2. Sync version to native files +yarn sync-versions + +# 3. Commit the changes +git add . && git commit -m "Bump version" && git push +``` + +**๐Ÿš€ Then deploy with these yarn commands:** + +```sh +yarn mobile-deploy # Deploy to both iOS and Android +yarn mobile-deploy:ios # Deploy to iOS TestFlight only +yarn mobile-deploy:android # Deploy to Android Internal Testing only +``` + +These commands will show you a confirmation dialog with deployment details before proceeding. + +### โœ… Preferred Method: Yarn Commands + +**โš ๏ธ Always use the yarn deployment commands instead of running fastlane directly.** + +The yarn commands provide safety checks and handle both local and GitHub runner deployments: + +```sh +# Deploy to both platforms (recommended) +yarn mobile-deploy + +# Deploy to iOS TestFlight only +yarn mobile-deploy:ios + +# Deploy to Android Internal Testing only +yarn mobile-deploy:android +``` + +### Alternative: Direct Script Usage + +If you prefer to call the script directly: + +```sh +# Deploy to iOS TestFlight +node scripts/mobile-deploy-confirm.cjs ios + +# Deploy to Android Internal Testing +node scripts/mobile-deploy-confirm.cjs android + +# Deploy to both platforms +node scripts/mobile-deploy-confirm.cjs both +``` + +### Deployment Methods + +**GitHub Runner (Default):** +- Triggers GitHub Actions workflow +- Builds and uploads using GitHub infrastructure +- Requires repository secrets to be configured +- Recommended for most developers + +**Local Fastlane:** +- Builds and uploads directly from your machine +- Requires local certificates and API keys +- Set `FORCE_UPLOAD_LOCAL_DEV=true` to enable +- Only use if you have local development setup + +### Local Deployment (Advanced Users) + +If you have local certificates and API keys set up, you can use local deployment: + +```sh +# Deploy to internal testing using local fastlane (with confirmation) +yarn mobile-local-deploy # Deploy to both platforms using local fastlane +yarn mobile-local-deploy:ios # Deploy iOS to TestFlight Internal Testing +yarn mobile-local-deploy:android # Deploy Android to Google Play Internal Testing +``` + +**Important Notes:** +- All `mobile-local-deploy` commands use the same confirmation script as regular deployment +- Local deployment goes to **internal testing** (TestFlight Internal Testing / Google Play Internal Testing) +- This is safer than the previous behavior which went directly to production stores +- For production deployment, use the GitHub runner method or call fastlane directly (not recommended) + +**Why internal testing?** This provides the same safety as GitHub runner deployments while allowing you to use your local machine for building. + +### Direct Fastlane Commands (Not Recommended) + +โš ๏ธ **Use the confirmation script above instead of these direct commands.** + +The available fastlane lanes are documented in the auto-generated `README.md`, but you should prefer the yarn commands for safety and consistency. + +### Deployment Status + +After deployment, you can check the status: + +- **GitHub Runner:** Check [GitHub Actions](https://github.com/YOUR_ORG/YOUR_REPO/actions) for build progress +- **Local Fastlane:** Check the terminal output and app store dashboards directly +- **iOS:** Check [App Store Connect](https://appstoreconnect.apple.com) for TestFlight builds +- **Android:** Check [Google Play Console](https://play.google.com/console) for Internal Testing builds ## Prerequisites ๐Ÿ› ๏ธ Before working with this setup, ensure you have the following installed: +* **Node.js** - Version 18 or higher (for JavaScript dependencies and deployment scripts) +* **Yarn** - Package manager for JavaScript dependencies +* **Git** - Required for branch detection and status checking during deployments +* **GitHub CLI (`gh`)** - **Required** for GitHub runner deployments (default method) + - Install from [https://cli.github.com/](https://cli.github.com/) + - Authenticate with `gh auth login` after installation + - Used to trigger GitHub Actions workflows for deployments * **Ruby** - Fastlane requires Ruby (version 2.6.0 or higher recommended) * **Bundler** - For managing Ruby dependencies -* **Xcode** - For iOS development (latest stable version recommended) +* **Xcode** - For iOS development (Note: Local development currently requires Xcode 16.2 due to compatibility issues with 16.3) * **Android Studio** - For Android development -* **Node.js & Yarn** - For JavaScript dependencies * **Docker** - Optional, required for local testing with `act` ## Setup โš™๏ธ @@ -44,7 +168,7 @@ Before working with this setup, ensure you have the following installed: Fastlane requires various secrets to interact with the app stores and sign applications: 1. **Create Your Local Secrets File:** Copy the template file to create your secrets file: - + ```bash cp app/fastlane/.env.secrets.example app/fastlane/.env.secrets ``` @@ -57,16 +181,26 @@ Fastlane requires various secrets to interact with the app stores and sign appli ### Environment Secrets Reference ๐Ÿ“ +#### Core Project Secrets ๐Ÿ”ง + +| Secret | Description | +|--------|-------------| +| `IOS_PROJECT_NAME` | iOS project name (used for workspace and scheme references) | +| `IOS_PROJECT_SCHEME` | iOS project scheme name for building | +| `IOS_SIGNING_CERTIFICATE` | iOS signing certificate identifier | + #### Android Secrets ๐Ÿค– | Secret | Description | |--------|-------------| -| `ANDROID_KEYSTORE` | Path to keystore file used for signing Android apps | +| `ANDROID_KEYSTORE` | Base64 encoded keystore file for signing Android apps | +| `ANDROID_KEYSTORE_PATH` | Path where keystore will be written (auto-generated for local dev) | | `ANDROID_KEYSTORE_PASSWORD` | Password for the Android keystore | | `ANDROID_KEY_ALIAS` | Alias of the key in the keystore | | `ANDROID_KEY_PASSWORD` | Password for the specified key | | `ANDROID_PACKAGE_NAME` | Package name/application ID of the Android app | | `ANDROID_PLAY_STORE_JSON_KEY_BASE64` | Base64 encoded Google Play Store service account JSON key file for API access | +| `ANDROID_PLAY_STORE_JSON_KEY_PATH` | Path where JSON key will be written (auto-generated for local dev) | #### iOS Secrets ๐Ÿ @@ -74,16 +208,26 @@ Fastlane requires various secrets to interact with the app stores and sign appli |--------|-------------| | `IOS_APP_IDENTIFIER` | Bundle identifier for the iOS app | | `IOS_CONNECT_API_KEY_BASE64` | Base64 encoded App Store Connect API key for authentication | +| `IOS_CONNECT_API_KEY_PATH` | Path where API key will be written (auto-generated for local dev) | | `IOS_CONNECT_ISSUER_ID` | App Store Connect issuer ID associated with the API key | | `IOS_CONNECT_KEY_ID` | App Store Connect key ID for API access | -| `IOS_DIST_CERT_BASE64` | Base64 encoded iOS distribution certificate for code signing | +| `IOS_DIST_CERT_BASE64` | Base64 encoded iOS distribution certificate (.p12 file) for code signing | | `IOS_PROV_PROFILE_BASE64` | Base64 encoded provisioning profile for the app | | `IOS_PROV_PROFILE_NAME` | Name of the provisioning profile | +| `IOS_PROV_PROFILE_PATH` | Path where provisioning profile will be installed (auto-generated for local dev) | | `IOS_P12_PASSWORD` | Password for the p12 certificate file | | `IOS_TEAM_ID` | Apple Developer Team ID | | `IOS_TEAM_NAME` | Apple Developer Team name | | `IOS_TESTFLIGHT_GROUPS` | Comma-separated list of TestFlight groups to distribute the app to | +#### Slack Integration Secrets ๐Ÿ“ฑ + +| Secret | Description | +|--------|-------------| +| `SLACK_API_TOKEN` | Slack bot token for uploading build artifacts | +| `SLACK_CHANNEL_ID` | Slack channel ID where build notifications will be sent | +| `SLACK_ANNOUNCE_CHANNEL_NAME` | Channel name for announcements (defaults to "deploy-mobile") | + ## Workflow Overview ๐Ÿ”„ ### Fastlane Lanes @@ -109,10 +253,11 @@ The project uses several custom Fastlane lanes to handle different build and dep ### Deployment Flow 1. **Version Management**: Update version in package.json using bump scripts -2. **Build Process**: Run the appropriate lane for internal testing or production -3. **Auto Build Numbers**: System automatically increments build numbers -4. **Upload**: Artifacts are uploaded to respective app stores -5. **Notification**: Slack notifications sent upon successful builds +2. **Version Sync**: Run sync-versions to update native files +3. **Commit Changes**: Commit version changes to repository +4. **Build Process**: Run the appropriate lane for internal testing or production +5. **Upload**: Artifacts are uploaded to respective app stores (subject to permissions) +6. **Notification**: Slack notifications sent with build artifacts upon successful builds ## Local Development ๐Ÿ’ป @@ -122,48 +267,81 @@ Several scripts in `app/package.json` facilitate common Fastlane and versioning #### Debug Builds ๐Ÿž -**`yarn ios:fastlane-debug`** / **`yarn android:fastlane-debug`** +**`yarn ios:fastlane-debug`** -* Executes the `internal_test` Fastlane lane for the respective platforms +* Executes the `internal_test` Fastlane lane for iOS * Builds the app in a debug configuration for internal testing -* Uploads to TestFlight (iOS) or Google Play Internal Testing (Android) if permissions allow -* Cleans build directories (`ios/build`, `android/app/build`) before running +* Uploads to TestFlight if permissions allow +* Cleans build directories (`ios/build`) before running -#### Forced Local Deployment ๐Ÿš€ +**Direct Fastlane Commands** -**`yarn force-local-upload-deploy`** -**`yarn force-local-upload-deploy:ios`** -**`yarn force-local-upload-deploy:android`** +For Android builds, use Fastlane directly: -* Runs the `deploy` Fastlane lane with local development settings +* `bundle exec fastlane android internal_test` - Build and upload to Google Play Internal Testing +* `bundle exec fastlane android deploy` - Build and upload to Google Play Production + +For iOS builds, you can also use Fastlane directly: + +* `bundle exec fastlane ios internal_test` - Build and upload to TestFlight +* `bundle exec fastlane ios deploy` - Build and upload to App Store Connect + +#### Local Deployment with Confirmation ๐Ÿš€ + +**`yarn mobile-local-deploy`** +**`yarn mobile-local-deploy:ios`** +**`yarn mobile-local-deploy:android`** + +* Runs the `internal_test` Fastlane lane with local development settings * Uses `FORCE_UPLOAD_LOCAL_DEV=true` to bypass CI checks -* Useful for testing deployment process locally or manual deploys -* Cleans build directories first -* **Use with caution!** Will attempt to upload to production if you have permissions +* Shows confirmation dialog before proceeding +* Deploys to **internal testing** (TestFlight Internal Testing / Google Play Internal Testing) +* Requires local certificates and API keys to be configured +* **Use with caution!** Make sure you have proper local setup -#### Forced Local Testing ๐Ÿงช +**Alternative: Direct Fastlane Commands** -**`yarn force-local-upload-test`** -**`yarn force-local-upload-test:ios`** -**`yarn force-local-upload-test:android`** +For more control, you can run Fastlane directly with local development settings: -* Similar to deploy version, but runs `internal_test` lane locally -* Useful for testing the internal distribution process -* Uses `FORCE_UPLOAD_LOCAL_DEV=true` flag +* `FORCE_UPLOAD_LOCAL_DEV=true bundle exec fastlane ios internal_test` - Force local iOS testing +* `FORCE_UPLOAD_LOCAL_DEV=true bundle exec fastlane android internal_test` - Force local Android testing ### Version Management ๐Ÿท๏ธ +**โš ๏ธ Required before every deployment:** + **`yarn bump-version:major|minor|patch`** * Increments version in `package.json` according to semantic versioning * Creates version commit and tag automatically -* Calls `sync-versions` afterwards +* **Must be run before deployment** to ensure unique version numbers **`yarn sync-versions`** * Synchronizes the version from `package.json` to native files * Updates iOS `Info.plist` and Android `build.gradle` * Ensures consistency across JS bundle and native app wrappers +* **Must be run after bump-version** and before deployment + +**Complete Version Update Workflow:** + +```bash +# 1. Bump version (choose appropriate level) +yarn bump-version:patch # For bug fixes +yarn bump-version:minor # For new features +yarn bump-version:major # For breaking changes + +# 2. Sync to native files +yarn sync-versions + +# 3. Commit changes +git add . +git commit -m "Bump version to $(node -p "require('./package.json').version")" +git push + +# 4. Now you can deploy +yarn mobile-deploy +``` ### Local Testing with `act` ๐Ÿงฐ @@ -176,7 +354,7 @@ You can test the GitHub Actions workflow locally using [`act`](https://github.co ```bash # Test the Android build act -j build-android --secret-file app/fastlane/.env.secrets - + # Test the iOS build (limited functionality on non-macOS systems) act -j build-ios --secret-file app/fastlane/.env.secrets ``` @@ -185,7 +363,7 @@ You can test the GitHub Actions workflow locally using [`act`](https://github.co * When running with `act`, the environment variable `ACT=true` is set automatically * This causes certain steps to be skipped, like code signing and store uploads * You can modify the workflow file locally to focus on specific steps by adding `if: false` to steps you want to skip - + 4. **Limitations:** * iOS builds require macOS-specific tools not available in Docker * Certificate/provisioning profile handling may not work as expected @@ -200,6 +378,12 @@ The primary CI/CD workflow is defined in `.github/workflows/mobile-deploy.yml`. * **Push Events:** Runs on pushes to `dev` or `main` branches that change files in `app/` or the workflow file * **Pull Request Events:** Runs on PRs to `dev` or `main` branches that change files in `app/` or the workflow file +### Manual Deployments + +From the GitHub Actions page select **Mobile App Deployments** and use the +**Run workflow** button. Choose the desired platform (`ios`, `android`, or +`both`) to start the build jobs on demand. + ### Jobs The workflow consists of parallel jobs for each platform: @@ -210,7 +394,7 @@ Runs on `macos-latest` and performs the following steps: 1. Sets up the environment (Node.js, Ruby, CocoaPods) 2. Processes iOS secrets and certificates 3. Runs appropriate Fastlane lane based on branch -4. Commits updated build numbers back to the repository +4. Builds and deploys the application using the manually set version #### `build-android` Job @@ -218,98 +402,166 @@ Runs on `ubuntu-latest` and performs the following steps: 1. Sets up the environment (Node.js, Java, Android SDK) 2. Processes Android secrets 3. Runs appropriate Fastlane lane based on branch -4. Commits updated version code back to the repository +4. Builds and deploys the application using the manually set version ### Deployment Destinations -* **Internal Testing:** +* **Internal Testing:** * iOS: TestFlight * Android: Google Play Internal Testing track * Triggered on pushes to `dev` branch and pull requests -* **Production:** +* **Production:** * iOS: App Store Connect (ready for submission) * Android: Google Play Production track * Triggered on pushes to `main` branch -## Auto Build Number Incrementing ๐Ÿ”ข +## Manual Build Number Management ๐Ÿ”ข -The CI/CD pipeline automatically manages build numbers/version codes: +Build numbers and version codes must be manually incremented before deployment using the provided scripts: + +### Prerequisites for Deployment + +**โš ๏ธ Important:** Before running any deployment commands, you must manually increment the build version using these steps: + +1. **Update Version Number:** + ```bash + # Increment version in package.json (choose one) + yarn bump-version:major # For major releases (1.0.0 โ†’ 2.0.0) + yarn bump-version:minor # For minor releases (1.0.0 โ†’ 1.1.0) + yarn bump-version:patch # For patch releases (1.0.0 โ†’ 1.0.1) + ``` + +2. **Sync to Native Files:** + ```bash + # Synchronize version from package.json to native files + yarn sync-versions + ``` + +3. **Commit Changes:** + ```bash + # Commit the version changes + git add . + git commit -m "Bump version to $(node -p "require('./package.json').version")" + git push + ``` ### iOS Build Numbers -1. **Automatic Fetching:** - * The pipeline fetches the latest build number from TestFlight via the App Store Connect API - * Increments by 1 for the new build +1. **Manual Management:** + * Build numbers are managed through the version bump scripts + * The `sync-versions` script updates `Info.plist` and Xcode project files + * Each deployment requires a unique build number higher than the previous version -2. **Implementation:** - ```ruby - latest_build = Fastlane::Actions::LatestTestflightBuildNumberAction.run( - api_key: api_key, - app_identifier: ENV["IOS_APP_IDENTIFIER"], - platform: "ios", - ) - new_build_number = latest_build + 1 - ``` - -3. **Commit Back to Repository:** - * After incrementing, changes are automatically committed back to the branch - * Files affected: `./app/ios/OpenPassport/Info.plist` and `./app/ios/Self.xcodeproj/project.pbxproj` +2. **Files Updated:** + * `./app/ios/OpenPassport/Info.plist` - `CFBundleVersion` + * `./app/ios/Self.xcodeproj/project.pbxproj` - `CURRENT_PROJECT_VERSION` ### Android Version Code -1. **Local Incrementing:** - * The pipeline increments the version code in the Gradle file - * Cannot verify against Google Play due to permission issues (see Android Caveats) +1. **Manual Management:** + * Version codes are managed through the version bump scripts + * The `sync-versions` script updates the `build.gradle` file + * Each deployment requires a unique version code higher than the previous version -2. **Commit Back to Repository:** - * After building, the workflow commits the incremented version code - * File affected: `./app/android/app/build.gradle` - -## Slack Notifications ๐Ÿ’ฌ - -The CI/CD pipeline sends notifications to Slack after successful builds: - -1. **Configuration:** - * Set `SLACK_API_TOKEN` and `SLACK_ANNOUNCE_CHANNEL_NAME` in your `.env.secrets` file - * For CI, add these as GitHub Actions Secrets - -2. **Notification Content:** - * iOS: `๐ŸŽ iOS v{version} (Build {build_number}) deployed to TestFlight/App Store Connect` - * Android: `๐Ÿค– Android v{version} (Build {version_code}) deployed to Internal Testing/Google Play` - * Includes the built artifact (IPA/AAB) as an attachment - -3. **Testing Notifications:** - * You can test Slack notifications locally with the `force-local-upload-test` scripts - * Requires a valid Slack API token with proper permissions +2. **Files Updated:** + * `./app/android/app/build.gradle` - `versionCode` and `versionName` ## Platform-Specific Notes ๐Ÿ“ฑ ### Android Deployment Caveats โš ๏ธ -There are important limitations when working with Android deployments: +**Critical:** The Android deployment system has important limitations: 1. **Google Play Store Permission Limitations:** * The pipeline currently **lacks permissions** to directly upload builds to the Google Play Store - * The `android_has_permissions` flag in helpers.rb is set to false, preventing direct uploads + * The `android_has_permissions` flag in the Fastfile is set to `false`, preventing direct uploads + * This is a hardcoded limitation in the current implementation 2. **Manual Upload Process Required:** * After the Android build job finishes, you must: - 1. Download the AAB artifact from the GitHub Actions run - 2. Manually upload the AAB file to the Google Play Console - 3. Complete the release process in the Play Console UI + 1. Download the `app-release.aab` artifact from the GitHub Actions run + (under **Artifacts** on the workflow summary page) + 2. Sign in to the Google Play Console and create a new release + 3. Upload the downloaded AAB file and follow the console prompts + 4. Complete the release process in the Play Console UI + * The CI/CD pipeline uses `bundle exec fastlane android internal_test` directly 3. **Version Code Management:** - * Unlike iOS, we cannot automatically fetch the current Android build number (version code) - * After building, you need to manually commit the updated version number + * Version codes must be manually incremented using the `bump-version` scripts before deployment + * The `sync-versions` script updates the version code in the Gradle file + * Ensure version codes are properly incremented and committed before running deployment commands 4. **For Local Developers:** - * When testing Android deployment locally: - ```bash - yarn android:build-release # Build the AAB - # The AAB will be in android/app/build/outputs/bundle/release/app-release.aab - ``` - * Note that the `force-local-upload-deploy:android` script will attempt to deploy but will fail due to permission issues + * When testing Android deployment locally, the AAB file will be generated but upload will be skipped + * The system will still send Slack notifications with the built artifact + +### iOS Development Notes ๐Ÿ + +1. **Code Signing:** + * The system automatically sets up manual code signing for consistency + * Certificates and provisioning profiles are automatically decoded and installed for local development + +2. **Build Configuration:** + * Uses Apple Generic Versioning system for build number management + * Automatically configures export options for App Store distribution + +## Advanced Features ๐Ÿ”ง + +### Error Handling and Retry Logic + +The helpers include sophisticated error handling: + +1. **Retry Logic:** + ```ruby + with_retry(max_retries: 3, delay: 5) do + # Operation that might fail + end + ``` + +2. **Standardized Error Reporting:** + * `report_error(message, suggestion, abort_message)` - Displays error and aborts + * `report_success(message)` - Displays success message with checkmark + * All critical operations use consistent error reporting + +3. **Environment Variable Verification:** + * Automatic verification of required environment variables before build + * Clear error messages indicating missing variables + +### Slack Integration + +The Slack integration is sophisticated and handles file uploads: + +1. **File Upload Process:** + * Uses Slack's three-step upload process (getUploadURL โ†’ upload โ†’ completeUpload) + * Includes retry logic for network failures + * Uploads actual build artifacts (IPA/AAB files) to Slack channels + +2. **Notification Format:** + * iOS: `๐ŸŽ iOS v{version} (Build {build_number}) deployed to TestFlight/App Store Connect` + * Android: `๐Ÿค– Android v{version} (Build {version_code}) deployed to Internal Testing/Google Play` + +3. **Configuration:** + * Requires `SLACK_API_TOKEN` and `SLACK_CHANNEL_ID` + * Fallback to `SLACK_ANNOUNCE_CHANNEL_NAME` for channel configuration + +### Local Development Helpers + +The system includes extensive helpers for local development: + +1. **iOS Certificate Management:** + * Automatically decodes and installs certificates from base64 environment variables + * Handles provisioning profile installation and UUID extraction + * Includes keychain diagnostics for troubleshooting + +2. **Android Keystore Management:** + * Automatically creates keystore files from base64 environment variables + * Handles Play Store JSON key setup for local development + +3. **CI Detection:** + * Automatically detects CI environment vs local development + * Skips certain operations when running in `act` (local CI testing) + * Handles forced uploads with confirmation prompts ## Troubleshooting ๐Ÿ” @@ -325,41 +577,83 @@ If you encounter issues with version syncing between `package.json` and native p 2. **Version Mismatch Checking:** ```bash + # Check version in package.json + node -p "require('./package.json').version" + # Check version in Info.plist /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" app/ios/OpenPassport/Info.plist - + # Check version in build.gradle grep "versionName" app/android/app/build.gradle ``` 3. **Fixing Discrepancies:** - * Always update `package.json` version first using the `bump-version` scripts - * Then run `sync-versions` to update native files - * For manual fixes, edit the version in each file and commit the changes + * **Always update `package.json` version first** using the `bump-version` scripts: + ```bash + yarn bump-version:patch # or minor/major + ``` + * Then run `sync-versions` to update native files: + ```bash + yarn sync-versions + ``` + * Commit all changes before deploying: + ```bash + git add . + git commit -m "Bump version to $(node -p "require('./package.json').version")" + git push + ``` + * **Never manually edit version numbers** in native files - always use the scripts to prevent inconsistencies ### iOS Build Issues 1. **Certificate/Provisioning Profile Errors** - * Ensure your certificate and provisioning profile are valid and not expired - * Verify that the correct team ID is being used - * Try using `fastlane match` to manage certificates and profiles + * Verify certificates are not expired and have proper base64 encoding + * Check that the correct team ID is being used + * Ensure provisioning profile matches the app identifier and certificates + * Use the built-in keychain diagnostics for troubleshooting 2. **TestFlight Upload Failures** * Check that your App Store Connect API key has sufficient permissions - * Verify your app's version and build numbers are incremented properly + * Verify build number was manually incremented using bump-version scripts * Ensure binary is properly signed with distribution certificate +3. **Xcode Version Issues** + * Ensure you're using Xcode 16.2 for local development + * Check that the correct Xcode version is selected with `xcode-select` + ### Android Build Issues 1. **Keystore Issues** - * Verify keystore path, password, and key alias are correct - * Check file permissions on the keystore file - * Ensure you're using the correct signing configuration in Gradle + * Verify keystore is properly base64 encoded in environment variables + * Check that keystore password, key alias, and key password are correct + * Ensure the keystore file is being created properly by the helper -2. **Google Play Upload Failures** - * Verify the service account has proper permissions in the Google Play Console - * Check that the app's version code has been incremented - * Ensure the JSON key file is valid and not expired +2. **Google Play Upload Limitations** + * Remember that uploads are currently disabled due to permission limitations + * Manual upload via Google Play Console is required + * Ensure version codes are manually incremented using bump-version scripts before building + +3. **Build Failures** + * Check that all required environment variables are set + * Verify Gradle build is working with the correct signing configuration + * Use the retry logic for transient network issues + +### Common Issues + +1. **Environment Variable Issues** + * Use `verify_env_vars` function to check all required variables + * Ensure base64 encoding is correct for certificate/key files + * Check that secrets are properly configured in CI/CD + +2. **Network and Permission Issues** + * Most operations include retry logic with exponential backoff + * Check API permissions for App Store Connect and Google Play + * Verify Slack bot permissions for file uploads + +3. **Local Development Setup** + * Ensure `.env.secrets` file is properly configured + * Use the force upload confirmation prompts carefully + * Check that all required development tools are installed ## Additional Resources ๐Ÿ“š @@ -375,3 +669,13 @@ If you encounter issues with version syncing between `package.json` and native p * [Match](https://docs.fastlane.tools/actions/match/) - Fastlane tool for iOS code signing * [Supply](https://docs.fastlane.tools/actions/supply/) - Fastlane tool for Android app deployment * [Gym](https://docs.fastlane.tools/actions/gym/) - Fastlane tool for building iOS apps +* [Slack API Documentation](https://api.slack.com/) - For setting up Slack integration + +### Internal Helper Documentation + +The project includes several custom helper modules: + +* `helpers/common.rb` - Core utilities, error handling, and retry logic +* `helpers/ios.rb` - iOS-specific build number management and certificate handling +* `helpers/android.rb` - Android-specific version code management and keystore handling +* `helpers/slack.rb` - Slack integration for build notifications and file uploads diff --git a/app/fastlane/Fastfile b/app/fastlane/Fastfile index 7b2af4acd..f8dfd6742 100644 --- a/app/fastlane/Fastfile +++ b/app/fastlane/Fastfile @@ -9,6 +9,7 @@ opt_out_usage require "bundler/setup" require "base64" +require "xcodeproj" require_relative "helpers" # load secrets before project configuration @@ -17,14 +18,8 @@ is_ci = Fastlane::Helpers.is_ci_environment? local_development = !is_ci # checks after calling Dotenv.load -attempt_force_upload_local_dev = ENV["FORCE_UPLOAD_LOCAL_DEV"] == "true" android_has_permissions = false -if local_development - # confirm that we want to force upload - Fastlane::Helpers.confirm_force_upload if attempt_force_upload_local_dev -end - # Project configuration PROJECT_NAME = ENV["IOS_PROJECT_NAME"] PROJECT_SCHEME = ENV["IOS_PROJECT_SCHEME"] @@ -78,36 +73,10 @@ platform :ios do end end - desc "Prepare a new build for App Store submission" - lane :deploy do - result = prepare_ios_build(prod_release: true) - - upload_to_app_store( - api_key: result[:api_key], - skip_screenshots: true, - skip_metadata: true, - submit_for_review: false, - automatic_release: false, - skip_app_version_update: true, - ) if result[:should_upload] - - # Notify Slack about the new build - if ENV["SLACK_CHANNEL_ID"] - Fastlane::Helpers.upload_file_to_slack( - file_path: result[:ipa_path], - channel_id: ENV["SLACK_CHANNEL_ID"], - initial_comment: "๐ŸŽ iOS (Ready for Submission) v#{package_version} (Build #{result[:build_number]}) deployed to App Store Connect", - title: "#{PROJECT_NAME}-#{package_version}-#{result[:build_number]}.ipa", - ) - else - UI.important("Skipping Slack notification: SLACK_CHANNEL_ID not set.") - end - end - private_lane :prepare_ios_build do |options| if local_development # app breaks with Xcode 16.3 - xcode_select "/Applications/Xcode-16-2.app" + xcode_select "/Applications/Xcode.app" # Set up API key, profile, and potentially certificate for local dev Fastlane::Helpers.ios_dev_setup_connect_api_key(ios_connect_api_key_path) @@ -141,7 +110,14 @@ platform :ios do ios_signing_certificate_name = "iPhone Distribution: #{ENV["IOS_TEAM_NAME"]} (#{ENV["IOS_TEAM_ID"]})" Fastlane::Helpers.verify_env_vars(required_env_vars) - build_number = Fastlane::Helpers.ios_increment_build_number(ios_xcode_profile_path) + + # Get current build number without auto-incrementing + project = Xcodeproj::Project.open(ios_xcode_profile_path) + target = project.targets.first + config = target.build_configurations.first + build_number = config.build_settings["CURRENT_PROJECT_VERSION"] + + # Verify build number is higher than TestFlight (but don't auto-increment) Fastlane::Helpers.ios_verify_app_store_build_number(ios_xcode_profile_path) Fastlane::Helpers.ios_verify_provisioning_profile @@ -244,7 +220,12 @@ platform :android do ] Fastlane::Helpers.verify_env_vars(required_env_vars) - version_code = Fastlane::Helpers.android_increment_version_code(android_gradle_file_path) + + # Get current version code without auto-incrementing + content = File.read(android_gradle_file_path) + match = content.match(/versionCode\s+(\d+)/) + version_code = match ? match[1].to_i : 1 + # TODO: uncomment when we have the permissions to run this action # Fastlane::Helpers.android_verify_version_code(android_gradle_file_path) diff --git a/app/fastlane/README.md b/app/fastlane/README.md index 61b95b6cc..8a41e7a08 100644 --- a/app/fastlane/README.md +++ b/app/fastlane/README.md @@ -31,14 +31,6 @@ Sync ios version Push a new build to TestFlight Internal Testing -### ios deploy - -```sh -[bundle exec] fastlane ios deploy -``` - -Prepare a new build for App Store submission - ---- diff --git a/app/fastlane/helpers.rb b/app/fastlane/helpers.rb index 517f36c39..2ef4946d3 100644 --- a/app/fastlane/helpers.rb +++ b/app/fastlane/helpers.rb @@ -1,5 +1,4 @@ -# SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 - +# SPDX-License-Identifier: BUSL-1.1 require "bundler/setup" require "fastlane" require "tempfile" @@ -10,732 +9,19 @@ require "net/http" require "uri" require "json" -# Load secrets before defining constants +require_relative "helpers/common" +require_relative "helpers/ios" +require_relative "helpers/android" +require_relative "helpers/slack" + module Fastlane module Helpers - def self.is_ci_environment? - ENV["CI"] == "true" && ENV["ACT"] != "true" - end - - def self.dev_load_dotenv_secrets - if !is_ci_environment? - puts "Loading .env.secrets" - require "dotenv" - Dotenv.load("./.env.secrets") - end - end - - # Simple multipart boundary generator - def self.generate_boundary - "----FastlaneSlackUploadBoundary#{rand(1000000)}" - end + extend Fastlane::Helpers::Common + extend Fastlane::Helpers::IOS + extend Fastlane::Helpers::Android + extend Fastlane::Helpers::Slack end end -# Call load_dotenv_secrets before setting constants +# Load secrets as early as possible Fastlane::Helpers.dev_load_dotenv_secrets - -# Now set constants after secrets are loaded -SLACK_TOKEN = ENV["SLACK_API_TOKEN"] -CHANNEL_NAME = ENV["SLACK_ANNOUNCE_CHANNEL_NAME"] || "deploy-mobile" - -module Fastlane - module Helpers - @@android_has_permissions = false - - ### UI and Reporting Methods ### - def self.report_error(message, suggestion = nil, abort_message = nil) - UI.error("โŒ #{message}") - UI.error(suggestion) if suggestion - UI.abort_with_message!(abort_message || message) - end - - def self.report_success(message) - UI.success("โœ… #{message}") - end - - ### Environment and Configuration Methods ### - def self.verify_env_vars(required_vars) - missing_vars = required_vars.select { |var| ENV[var].nil? || ENV[var].to_s.strip.empty? } - - if missing_vars.any? - report_error( - "Missing required environment variables: #{missing_vars.join(", ")}", - "Please check your secrets", - "Environment verification failed" - ) - else - report_success("All required environment variables are present") - end - end - - def self.should_upload_app(platform) - if ENV["ACT"] == "true" - puts "Skipping upload to #{platform} we are testing using `act`" - return false - end - - if ENV["IS_PR"] == "true" - puts "Skipping upload to #{platform} because we are in a pull request" - return false - end - - # upload app if we are in CI or forcing local upload - ENV["CI"] == "true" || ENV["FORCE_UPLOAD_LOCAL_DEV"] == "true" - end - - def self.confirm_force_upload - UI.important "โš ๏ธ FORCE_UPLOAD_LOCAL_DEV is set to true. This will upload the build to the store." - UI.important "Are you sure you want to continue? (y/n)" - response = STDIN.gets.chomp - unless response.downcase == "y" - UI.user_error!("Upload cancelled by user") - end - end - - def self.with_retry(max_retries: 3, delay: 5) - attempts = 0 - begin - yield - rescue => e - attempts += 1 - if attempts < max_retries - UI.important("Retry ##{attempts} after error: #{e.message}") - sleep(delay) - retry - else - UI.user_error!("Failed after #{max_retries} retries: #{e.message}") - end - end - end - - def self.ios_verify_app_store_build_number(ios_xcode_profile_path) - api_key = Fastlane::Actions::AppStoreConnectApiKeyAction.run( - key_id: ENV["IOS_CONNECT_KEY_ID"], - issuer_id: ENV["IOS_CONNECT_ISSUER_ID"], - key_filepath: ENV["IOS_CONNECT_API_KEY_PATH"], - in_house: false, - ) - - latest_build = Fastlane::Actions::LatestTestflightBuildNumberAction.run( - api_key: api_key, - app_identifier: ENV["IOS_APP_IDENTIFIER"], - platform: "ios", - ) - - project = Xcodeproj::Project.open(ios_xcode_profile_path) - target = project.targets.first - current_build = target.build_configurations.first.build_settings["CURRENT_PROJECT_VERSION"] - - if current_build.to_i <= latest_build.to_i - report_error( - "Build number must be greater than latest TestFlight build!", - "Latest TestFlight build: #{latest_build}\nCurrent build: #{current_build}\nPlease increment the build number in the project settings", - "Build number verification failed" - ) - else - report_success("Build number verified (Current: #{current_build}, Latest TestFlight: #{latest_build})") - end - end - - def self.ios_ensure_generic_versioning(ios_xcode_profile_path) - puts "Opening Xcode project at: #{File.expand_path(ios_xcode_profile_path)}" - - unless File.exist?(ios_xcode_profile_path) - report_error( - "Xcode project not found at #{project_path}", - "Please ensure you're running this command from the correct directory", - "Project file not found" - ) - end - - project = Xcodeproj::Project.open(ios_xcode_profile_path) - - project.targets.each do |target| - target.build_configurations.each do |config| - if config.build_settings["VERSIONING_SYSTEM"] != "apple-generic" - puts "Enabling Apple Generic Versioning for #{target.name} - #{config.name}" - config.build_settings["VERSIONING_SYSTEM"] = "apple-generic" - config.build_settings["CURRENT_PROJECT_VERSION"] ||= "1" - end - end - end - - project.save - report_success("Enabled Apple Generic Versioning in Xcode project") - end - - def self.ios_increment_build_number(ios_xcode_profile_path) - # First ensure Apple Generic Versioning is enabled - ios_ensure_generic_versioning(ios_xcode_profile_path) - - api_key = Fastlane::Actions::AppStoreConnectApiKeyAction.run( - key_id: ENV["IOS_CONNECT_KEY_ID"], - issuer_id: ENV["IOS_CONNECT_ISSUER_ID"], - key_filepath: ENV["IOS_CONNECT_API_KEY_PATH"], - in_house: false, - ) - - latest_build = Fastlane::Actions::LatestTestflightBuildNumberAction.run( - api_key: api_key, - app_identifier: ENV["IOS_APP_IDENTIFIER"], - platform: "ios", - ) - - new_build_number = latest_build + 1 - - Fastlane::Actions::IncrementBuildNumberAction.run( - build_number: new_build_number, - xcodeproj: ios_xcode_profile_path, - ) - - report_success("Incremented build number to #{new_build_number} (previous TestFlight build: #{latest_build})") - - new_build_number - end - - def self.ios_dev_setup_certificate - unless ENV["IOS_DIST_CERT_BASE64"] - report_error( - "Missing IOS_DIST_CERT_BASE64 environment variable.", - "This variable is required for local certificate installation.", - "Certificate installation failed" - ) - end - unless ENV["IOS_P12_PASSWORD"] - report_error( - "Missing IOS_P12_PASSWORD environment variable.", - "This password is required to import the certificate (.p12 file).", - "Certificate installation failed" - ) - end - - decoded_cert_data = Base64.decode64(ENV["IOS_DIST_CERT_BASE64"]) - if decoded_cert_data.empty? - report_error( - "IOS_DIST_CERT_BASE64 seems to be empty or invalid.", - "Please check the value of the environment variable.", - "Certificate decoding failed" - ) - end - - cert_password = ENV["IOS_P12_PASSWORD"] || "" - temp_p12 = nil - - begin - temp_p12 = Tempfile.new(["fastlane_local_cert", ".p12"]) - temp_p12.binmode - temp_p12.write(decoded_cert_data) - temp_p12.close - puts "Temporarily wrote decoded certificate to: #{temp_p12.path}" - - # Import the certificate into the default keychain - # Omitting -k targets the default keychain. - # -T /usr/bin/codesign allows codesign to use the key without prompting every time. - import_command = "security import #{Shellwords.escape(temp_p12.path)} -P #{Shellwords.escape(cert_password)} -T /usr/bin/codesign" - puts "Running: #{import_command}" - import_output = `#{import_command} 2>&1` - - unless $?.success? - report_error( - "Failed to import certificate into default keychain.", - "Command: #{import_command}\nOutput: #{import_output}", - "Certificate import failed" - ) - end - report_success("Certificate imported successfully into default keychain.") - rescue => e - report_error("An error occurred during certificate installation: #{e.message}", e.backtrace.join("\n"), "Certificate installation failed") - ensure - # Clean up temporary file - if temp_p12 - temp_p12.unlink - puts "Cleaned up temp certificate: #{temp_p12.path}" - end - end - end - - def self.ios_dev_setup_connect_api_key(api_key_path) - api_key_full_path = File.expand_path(api_key_path, File.dirname(__FILE__)) - ENV["IOS_CONNECT_API_KEY_PATH"] = api_key_full_path - - if ENV["IOS_CONNECT_API_KEY_BASE64"] - puts "Decoding iOS Connect API key..." - begin - decoded_key = Base64.decode64(ENV["IOS_CONNECT_API_KEY_BASE64"]) - if decoded_key.empty? - report_error( - "IOS_CONNECT_API_KEY_BASE64 seems to be empty or invalid.", - "Please check the value of the environment variable.", - "Connect API Key decoding failed" - ) - end - FileUtils.mkdir_p(File.dirname(api_key_full_path)) - File.write(api_key_full_path, decoded_key) - report_success("Connect API Key written to: #{api_key_full_path}") - rescue => e - report_error("Error writing decoded API key: #{e.message}", nil, "Connect API Key setup failed") - end - elsif !File.exist?(api_key_full_path) - report_error( - "IOS_CONNECT_API_KEY_BASE64 not set and key file not found.", - "Please provide the key via environment variable or ensure it exists at #{api_key_full_path}", - "Connect API Key setup failed" - ) - else - puts "Using existing Connect API Key at: #{api_key_full_path}" - end - - begin - verified_path = File.realpath(api_key_full_path) - puts "Verified Connect API Key path: #{verified_path}" - verified_path - rescue Errno::ENOENT - report_error("Connect API Key file not found at expected location: #{api_key_full_path}", nil, "Connect API Key verification failed") - end - end - - def self.ios_dev_setup_provisioning_profile(provisioning_profile_directory) - unless ENV["IOS_PROV_PROFILE_BASE64"] - report_error( - "Missing IOS_PROV_PROFILE_BASE64 environment variable.", - "This variable is required for local development profile setup.", - "Provisioning profile setup failed" - ) - end - - decoded_profile_data = Base64.decode64(ENV["IOS_PROV_PROFILE_BASE64"]) - if decoded_profile_data.empty? - report_error( - "IOS_PROV_PROFILE_BASE64 seems to be empty or invalid.", - "Please check the value of the environment variable.", - "Provisioning profile decoding failed" - ) - end - - temp_profile = nil - temp_plist = nil - final_path = nil - - begin - temp_profile = Tempfile.new(["fastlane_local_profile", ".mobileprovision"]) - temp_profile.binmode - temp_profile.write(decoded_profile_data) - temp_profile.close - puts "Temporarily wrote decoded profile to: #{temp_profile.path}" - - temp_plist = Tempfile.new(["fastlane_temp_plist", ".plist"]) - temp_plist_path = temp_plist.path - temp_plist.close - puts "Temporary plist path: #{temp_plist_path}" - - security_command = "security cms -D -i #{Shellwords.escape(temp_profile.path)} -o #{Shellwords.escape(temp_plist_path)}" - puts "Running: #{security_command}" - security_output = `#{security_command} 2>&1` - - unless $?.success? - report_error( - "Failed to extract plist from provisioning profile using security cms.", - "Command failed: #{security_command}\nOutput: #{security_output}", - "Provisioning profile UUID extraction failed" - ) - end - puts "Successfully extracted plist." - - unless File.exist?(temp_plist_path) && File.size(temp_plist_path) > 0 - report_error( - "Plist file was not created or is empty after security command.", - "Expected plist at: #{temp_plist_path}", - "Provisioning profile UUID extraction failed" - ) - end - - plistbuddy_command = "/usr/libexec/PlistBuddy -c \"Print :UUID\" #{Shellwords.escape(temp_plist_path)}" - puts "Running: #{plistbuddy_command}" - profile_uuid = `#{plistbuddy_command} 2>&1`.strip - - unless $?.success? && !profile_uuid.empty? && profile_uuid !~ /does not exist/ - report_error( - "Failed to extract UUID using PlistBuddy or UUID was empty.", - "Command: #{plistbuddy_command}\nOutput: #{profile_uuid}", - "Provisioning profile UUID extraction failed" - ) - end - report_success("Extracted profile UUID: #{profile_uuid}") - - profile_dir = File.expand_path(provisioning_profile_directory) - FileUtils.mkdir_p(profile_dir) - final_path = File.join(profile_dir, "#{profile_uuid}.mobileprovision") - - puts "Copying profile to: #{final_path}" - FileUtils.cp(temp_profile.path, final_path) - report_success("Provisioning profile installed successfully.") - - ENV["IOS_PROV_PROFILE_PATH"] = final_path - rescue => e - report_error("An error occurred during provisioning profile setup: #{e.message}", e.backtrace.join("\n"), "Provisioning profile setup failed") - ensure - if temp_profile - temp_profile.unlink - puts "Cleaned up temp profile: #{temp_profile.path}" - end - if temp_plist_path && File.exist?(temp_plist_path) - File.unlink(temp_plist_path) - puts "Cleaned up temp plist: #{temp_plist_path}" - end - end - - final_path - end - - def self.ios_verify_provisioning_profile - profile_path = ENV["IOS_PROV_PROFILE_PATH"] - - unless profile_path && !profile_path.empty? - report_error( - "ENV['IOS_PROV_PROFILE_PATH'] is not set.", - "Ensure ios_dev_setup_provisioning_profile ran successfully or the path is set correctly in CI.", - "Provisioning profile verification failed" - ) - end - - puts "Verifying provisioning profile exists at: #{profile_path}" - - begin - File.realpath(profile_path) - report_success("iOS provisioning profile verified successfully at #{profile_path}") - rescue Errno::ENOENT - report_error("Provisioning profile not found at: #{profile_path}") - rescue => e - report_error("Error accessing provisioning profile at #{profile_path}: #{e.message}") - end - - # Print current user - current_user = ENV["USER"] || `whoami`.strip - puts "Current user: #{current_user}" - - # List all provisioning profiles in user's directory - profiles_dir = File.expand_path("~/Library/MobileDevice/Provisioning Profiles") - if Dir.exist?(profiles_dir) - puts "Listing mobile provisioning profiles in #{profiles_dir}:" - profiles = Dir.glob(File.join(profiles_dir, "*.mobileprovision")) - if profiles.empty? - puts " No provisioning profiles found" - else - profiles.each do |profile| - uuid = File.basename(profile, ".mobileprovision") - puts " - #{uuid}.mobileprovision" - end - puts "Total provisioning profiles found: #{profiles.count}" - end - else - puts "Provisioning profiles directory not found at: #{profiles_dir}" - end - - # Advanced checks for provisioning profile - puts "\n--- Advanced Provisioning Profile Diagnostics ---" - - # Check if profile can be parsed - if File.exist?(profile_path) - puts "Testing if profile can be parsed with security tool:" - temp_plist = Tempfile.new(["profile_info", ".plist"]) - begin - security_cmd = "security cms -D -i #{Shellwords.escape(profile_path)} -o #{Shellwords.escape(temp_plist.path)}" - security_output = `#{security_cmd} 2>&1` - security_success = $?.success? - - if security_success - puts "โœ… Profile can be parsed successfully" - - # Extract and display important profile information - puts "\nExtracting profile information:" - - # Get profile UUID - uuid_cmd = "/usr/libexec/PlistBuddy -c 'Print :UUID' #{Shellwords.escape(temp_plist.path)}" - uuid = `#{uuid_cmd}`.strip - puts "Profile UUID: #{uuid}" - - # Get App ID/Bundle ID - app_id_cmd = "/usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' #{Shellwords.escape(temp_plist.path)}" - app_id = `#{app_id_cmd}`.strip - puts "App Identifier: #{app_id}" - - # Get Team ID - team_id_cmd = "/usr/libexec/PlistBuddy -c 'Print :TeamIdentifier:0' #{Shellwords.escape(temp_plist.path)}" - team_id = `#{team_id_cmd}`.strip - puts "Team Identifier: #{team_id}" - - # Get profile type (development, distribution, etc.) - profile_type_cmd = "/usr/libexec/PlistBuddy -c 'Print :Entitlements:get-task-allow' #{Shellwords.escape(temp_plist.path)} 2>/dev/null" - get_task_allow = `#{profile_type_cmd}`.strip.downcase - - if get_task_allow == "true" - puts "Profile Type: Development" - else - distribution_cmd = "/usr/libexec/PlistBuddy -c 'Print :ProvisionsAllDevices' #{Shellwords.escape(temp_plist.path)} 2>/dev/null" - provisions_all = `#{distribution_cmd}`.strip.downcase - - if provisions_all == "true" - puts "Profile Type: Enterprise Distribution" - else - puts "Profile Type: App Store Distribution" - end - end - - # Get expiration date - expiration_cmd = "/usr/libexec/PlistBuddy -c 'Print :ExpirationDate' #{Shellwords.escape(temp_plist.path)}" - expiration = `#{expiration_cmd}`.strip - puts "Expiration Date: #{expiration}" - else - puts "โŒ Failed to parse profile: #{security_output}" - end - ensure - temp_plist.close - temp_plist.unlink - end - end - - # Check code signing identities - puts "\nInspecting code signing identities:" - signing_identities = `security find-identity -v -p codesigning 2>&1` - puts signing_identities - - # Check keychain configuration - puts "\nKeychain configuration:" - puts `security list-keychains -d user 2>&1` - - # Check Xcode configuration - puts "\nXcode code signing search paths:" - puts "Provisioning profiles search path: ~/Library/MobileDevice/Provisioning Profiles/" - puts "Recommended check: In Xcode settings, verify your Apple ID is correctly logged in" - - puts "--- End of Provisioning Profile Diagnostics ---\n" - end - - ### Android-specific Methods ### - - def self.android_create_keystore(keystore_path) - if ENV["ANDROID_KEYSTORE"] - puts "Decoding Android keystore..." - FileUtils.mkdir_p(File.dirname(keystore_path)) - File.write(keystore_path, Base64.decode64(ENV["ANDROID_KEYSTORE"])) - end - - File.realpath(keystore_path) - end - - def self.android_create_play_store_key(key_path) - if ENV["ANDROID_PLAY_STORE_JSON_KEY_BASE64"] - puts "Decoding Android Play Store JSON key..." - FileUtils.mkdir_p(File.dirname(key_path)) - File.write(key_path, Base64.decode64(ENV["ANDROID_PLAY_STORE_JSON_KEY_BASE64"])) - end - - File.realpath(key_path) - end - - # unused to do api key permissions - def self.android_verify_version_code(gradle_file_path) - latest_version = Fastlane::Actions::GooglePlayTrackVersionCodesAction.run( - track: "internal", - json_key: ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"], - package_name: ENV["ANDROID_PACKAGE_NAME"], - ).first - - version_code_line = File.readlines(gradle_file_path).find { |line| line.include?("versionCode") } - current_version = version_code_line.match(/versionCode\s+(\d+)/)[1].to_i - - if current_version <= latest_version - report_error( - "Version code must be greater than latest Play Store version!", - "Latest Play Store version: #{latest_version}\nCurrent version: #{current_version}\nPlease increment the version code in android/app/build.gradle", - "Version code verification failed" - ) - else - report_success("Version code verified (Current: #{current_version}, Latest Play Store: #{latest_version})") - end - end - - def self.android_increment_version_code(gradle_file_path) - gradle_file_full_path = File.expand_path(gradle_file_path, File.dirname(__FILE__)) - - unless File.exist?(gradle_file_full_path) - UI.error("Could not find build.gradle at: #{gradle_file_full_path}") - UI.user_error!("Please ensure the Android project is properly set up") - end - - # Read current version code - gradle_content = File.read(gradle_file_full_path) - version_code_match = gradle_content.match(/versionCode\s+(\d+)/) - current_version_code = version_code_match ? version_code_match[1].to_i : 0 - - # TODO: fetch version code from play store when we have permissions - new_version = current_version_code + 1 - - # Update version code in file - if @@android_has_permissions - updated_content = gradle_content.gsub(/versionCode\s+\d+/, "versionCode #{new_version}") - File.write(gradle_file_full_path, updated_content) - end - - report_success("Version code incremented from #{current_version_code} to #{new_version}") - - @@android_has_permissions ? new_version : current_version_code - end - - # Helper to log keychain diagnostics - def self.log_keychain_diagnostics(certificate_name) - puts "--- Fastlane Pre-Build Diagnostics ---" - begin - system("echo 'Running as user: $(whoami)'") - system("echo 'Default keychain:'") - system("security list-keychains -d user") - system("echo 'Identities in build.keychain:'") - # Use the absolute path expected in the GH runner environment - keychain_path = "/Users/runner/Library/Keychains/build.keychain-db" - system("security find-identity -v -p codesigning #{keychain_path} || echo 'No identities found or build.keychain doesn\'t exist at #{keychain_path}'") - rescue => e - puts "Error running security command: #{e.message}" - end - puts "Certificate name constructed by Fastlane: #{certificate_name}" - puts "--- End Fastlane Diagnostics ---" - end - - ### Slack Methods ### - # Uploads a file to Slack using the files.upload API endpoint. - # Handles multipart/form-data request construction. - # - # Args: - # file_path (String): Path to the file to upload. - # channel_id (String): ID of the channel to upload the file to. - # initial_comment (String, optional): Message to post alongside the file. - # thread_ts (String, optional): Timestamp of a message to reply to (creates a thread). - # title (String, optional): Title for the uploaded file (defaults to filename). - def self.upload_file_to_slack(file_path:, channel_id:, initial_comment: nil, thread_ts: nil, title: nil) - unless SLACK_TOKEN && !SLACK_TOKEN.strip.empty? - report_error("Missing SLACK_API_TOKEN environment variable.", "Cannot upload file to Slack without API token.", "Slack Upload Failed") - return false - end - - unless File.exist?(file_path) - report_error("File not found at path: #{file_path}", "Please ensure the file exists before uploading.", "Slack Upload Failed") - return false - end - - file_name = File.basename(file_path) - file_size = File.size(file_path) - file_title = title || file_name - - begin - upload_url = nil - file_id = nil - - # Step 1: Get Upload URL - with_retry(max_retries: 3, delay: 5) do - UI.message("Step 1: Getting Slack upload URL for #{file_name}...") - uri = URI.parse("https://slack.com/api/files.getUploadURLExternal") - request = Net::HTTP::Post.new(uri) - request["Authorization"] = "Bearer #{SLACK_TOKEN}" - request.set_form_data(filename: file_name, length: file_size) - - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - response = http.request(request) - - unless response.is_a?(Net::HTTPSuccess) - raise "Slack API (files.getUploadURLExternal) failed: #{response.code} #{response.body}" - end - - response_json = JSON.parse(response.body) - unless response_json["ok"] - raise "Slack API Error (files.getUploadURLExternal): #{response_json["error"]}" - end - - upload_url = response_json["upload_url"] - file_id = response_json["file_id"] - UI.message("Got upload URL and file ID: #{file_id}") - end - - # Step 2: Upload file content to the obtained URL - with_retry(max_retries: 3, delay: 5) do - UI.message("Step 2: Uploading file content to Slack...") - upload_uri = URI.parse(upload_url) - # Net::HTTP::Post requires the request body to be an IO object or string - # Reading the file content here for the request body - file_content = File.binread(file_path) - - upload_request = Net::HTTP::Post.new(upload_uri) - upload_request.body = file_content - # Slack's upload URL expects the raw file bytes in the body - # Content-Type is often application/octet-stream, but Slack might infer - upload_request["Content-Type"] = "application/octet-stream" - upload_request["Content-Length"] = file_size.to_s - - upload_http = Net::HTTP.new(upload_uri.host, upload_uri.port) - upload_http.use_ssl = true - upload_response = upload_http.request(upload_request) - - # Check for a 200 OK response for the file upload itself - unless upload_response.is_a?(Net::HTTPOK) - raise "File content upload failed: #{upload_response.code} #{upload_response.message} Body: #{upload_response.body}" - end - UI.message("File content uploaded successfully.") - end - - # Step 3: Complete the upload - final_file_info = nil - with_retry(max_retries: 3, delay: 5) do - UI.message("Step 3: Completing Slack upload for file ID #{file_id}...") - complete_uri = URI.parse("https://slack.com/api/files.completeUploadExternal") - complete_request = Net::HTTP::Post.new(complete_uri) - complete_request["Authorization"] = "Bearer #{SLACK_TOKEN}" - complete_request["Content-Type"] = "application/json; charset=utf-8" - - payload = { - files: [{ id: file_id, title: file_title }], - channel_id: channel_id, - } - payload[:initial_comment] = initial_comment if initial_comment - payload[:thread_ts] = thread_ts if thread_ts - - complete_request.body = payload.to_json - - complete_http = Net::HTTP.new(complete_uri.host, complete_uri.port) - complete_http.use_ssl = true - complete_response = complete_http.request(complete_request) - - unless complete_response.is_a?(Net::HTTPSuccess) - raise "Slack API (files.completeUploadExternal) failed: #{complete_response.code} #{complete_response.body}" - end - - complete_response_json = JSON.parse(complete_response.body) - unless complete_response_json["ok"] - # Specific error handling for common issues - if complete_response_json["error"] == "invalid_channel" - UI.error("Error: Invalid SLACK_CHANNEL_ID: '#{channel_id}'. Please verify the channel ID.") - elsif complete_response_json["error"] == "channel_not_found" - UI.error("Error: Channel '#{channel_id}' not found. Ensure the bot is invited or the ID is correct.") - end - raise "Slack API Error (files.completeUploadExternal): #{complete_response_json["error"]} - #{complete_response_json["response_metadata"]&.[]("messages")&.join(", ")}" - end - - # Expecting an array of file objects - final_file_info = complete_response_json["files"]&.first - unless final_file_info - raise "Upload completed but no file information returned in response: #{complete_response.body}" - end - report_success("Successfully uploaded and shared #{file_name} (ID: #{final_file_info["id"]}) to Slack channel #{channel_id}") - end - - return final_file_info # Return the first file object on success - rescue JSON::ParserError => e - report_error("Failed to parse Slack API response.", "Error: #{e.message}", "Slack Upload Failed") - return false - rescue => e - # Include backtrace for better debugging - report_error("Error during Slack upload process: #{e.message}", e.backtrace.join("\n"), "Slack Upload Failed") - return false - end - end - end -end diff --git a/app/fastlane/helpers/android.rb b/app/fastlane/helpers/android.rb new file mode 100644 index 000000000..75b703043 --- /dev/null +++ b/app/fastlane/helpers/android.rb @@ -0,0 +1,90 @@ +module Fastlane + module Helpers + module Android + @@android_has_permissions = false + + def self.set_permissions(value) + @@android_has_permissions = value + end + + # Decode keystore from ENV for local development + def android_create_keystore(path) + return nil unless ENV["ANDROID_KEYSTORE"] + + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, Base64.decode64(ENV["ANDROID_KEYSTORE"])) + File.realpath(path) + end + + # Decode Play Store JSON key from ENV + def android_create_play_store_key(path) + return nil unless ENV["ANDROID_PLAY_STORE_JSON_KEY_BASE64"] + + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, Base64.decode64(ENV["ANDROID_PLAY_STORE_JSON_KEY_BASE64"])) + File.realpath(path) + end + + # Verify that the current version code is greater than the latest version on Play Store + # This method compares the versionCode in the gradle file against the latest version + # published on the Play Store internal track to ensure no version conflicts occur + # + # @param gradle_file [String] Path to the build.gradle file containing versionCode + # @return [void] Reports success or error based on version comparison + def android_verify_version_code(gradle_file) + latest = Fastlane::Actions::GooglePlayTrackVersionCodesAction.run( + track: "internal", + json_key: ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"], + package_name: ENV["ANDROID_PACKAGE_NAME"], + ).first + + line = File.readlines(gradle_file).find { |l| l.include?("versionCode") } + return report_error( + "Could not find versionCode in gradle file", + "Please ensure the gradle file contains a valid versionCode declaration", + "Version code verification failed" + ) unless line + + match = line.match(/versionCode\s+(\d+)/) + return report_error( + "Could not parse versionCode from gradle file", + "Expected format: versionCode ", + "Version code verification failed" + ) unless match + + current = match[1].to_i + + if current <= latest + report_error( + "Version code must be greater than latest Play Store version!", + "Latest: #{latest} Current: #{current}", + "Version code verification failed" + ) + else + report_success("Version code verified (Current: #{current}, Latest: #{latest})") + end + end + + # Increment version code locally (Play Store fetch disabled) + def android_increment_version_code(gradle_file) + full = File.expand_path(gradle_file) + raise "Could not find build.gradle" unless File.exist?(full) + content = File.read(full) + match = content.match(/versionCode\s+(\d+)/) + + raise "Could not find versionCode in gradle file. Expected format: versionCode " unless match + + current = match[1].to_i + new_version = current + 1 + if @@android_has_permissions + File.write(full, content.gsub(/versionCode\s+\d+/, "versionCode #{new_version}")) + report_success("Version code incremented from #{current} to #{new_version} and written to file") + new_version + else + report_success("Version code incremented from #{current} to #{new_version} (read-only mode)") + current + end + end + end + end +end diff --git a/app/fastlane/helpers/common.rb b/app/fastlane/helpers/common.rb new file mode 100644 index 000000000..3aa1942c6 --- /dev/null +++ b/app/fastlane/helpers/common.rb @@ -0,0 +1,89 @@ +module Fastlane + module Helpers + module Common + # Detect if running in CI (and not locally via `act`) + def is_ci_environment? + ENV["CI"] == "true" && ENV["ACT"] != "true" + end + + # Load development secrets when not in CI + def dev_load_dotenv_secrets + return if is_ci_environment? + require "dotenv" + puts "Loading .env.secrets" + Dotenv.load("./.env.secrets") + end + + # Display an error and abort execution + def report_error(message, suggestion = nil, abort_message = nil) + UI.error("โŒ #{message}") + UI.error(suggestion) if suggestion + UI.abort_with_message!(abort_message || message) + end + + # Display a success message + def report_success(message) + UI.success("โœ… #{message}") + end + + # Ensure a list of environment variables are present + def verify_env_vars(required_vars) + missing = required_vars.select { |var| ENV[var].to_s.strip.empty? } + if missing.any? + report_error( + "Missing required environment variables: #{missing.join(", ")}", + "Please check your secrets", + "Environment verification failed" + ) + else + report_success("All required environment variables are present") + end + end + + # Decide if a build should be uploaded to the store + def should_upload_app(platform) + return false if ENV["ACT"] == "true" || ENV["IS_PR"] == "true" + ENV["CI"] == "true" || ENV["FORCE_UPLOAD_LOCAL_DEV"] == "true" + end + + # Helper wrapper to retry a block with exponential backoff for rate limits + def with_retry(max_retries: 3, delay: 5) + attempts = 0 + begin + yield + rescue => e + attempts += 1 + if attempts < max_retries + # Check if this is a rate limit error (HTTP 429) + is_rate_limit = e.message.include?("429") || e.message.downcase.include?("rate limit") + + if is_rate_limit + # Exponential backoff for rate limits: 5s, 10s, 20s, 40s... + backoff_delay = delay * (2 ** (attempts - 1)) + UI.important("Rate limit hit. Retry ##{attempts} after #{backoff_delay}s: #{e.message}") + sleep(backoff_delay) + else + # Regular retry with fixed delay for other errors + UI.important("Retry ##{attempts} after #{delay}s: #{e.message}") + sleep(delay) + end + retry + else + UI.user_error!("Failed after #{max_retries} retries: #{e.message}") + end + end + end + + # Print basic keychain diagnostics + def log_keychain_diagnostics(certificate_name) + puts "--- Fastlane Pre-Build Diagnostics ---" + system("echo 'Running as user: $(whoami)'") + system("security list-keychains -d user") + keychain_path = "/Users/runner/Library/Keychains/build.keychain-db" + system("security find-identity -v -p codesigning #{keychain_path} || echo 'No identities found'") + puts "Certificate name constructed by Fastlane: #{certificate_name}" + puts "--- End Fastlane Diagnostics ---" + end + end + end +end diff --git a/app/fastlane/helpers/ios.rb b/app/fastlane/helpers/ios.rb new file mode 100644 index 000000000..a2c5f41fe --- /dev/null +++ b/app/fastlane/helpers/ios.rb @@ -0,0 +1,148 @@ +module Fastlane + module Helpers + module IOS + # Verify the build number is higher than TestFlight + def ios_verify_app_store_build_number(xcodeproj) + api_key = ios_connect_api_key + + latest = Fastlane::Actions::LatestTestflightBuildNumberAction.run( + api_key: api_key, + app_identifier: ENV["IOS_APP_IDENTIFIER"], + platform: "ios" + ) + + project = Xcodeproj::Project.open(xcodeproj) + target = project.targets.first + report_error("No targets found in Xcode project") unless target + + config = target.build_configurations.first + report_error("No build configurations found for target") unless config + + current = config.build_settings["CURRENT_PROJECT_VERSION"] + report_error("CURRENT_PROJECT_VERSION not set in build settings") unless current + + if current.to_i <= latest.to_i + report_error( + "Build number must be greater than latest TestFlight build!", + "Latest: #{latest} Current: #{current}", + "Build number verification failed" + ) + else + report_success("Build number verified (Current: #{current}, Latest: #{latest})") + end + end + + # Ensure Xcode project uses generic versioning + def ios_ensure_generic_versioning(xcodeproj) + raise "Xcode project not found" unless File.exist?(xcodeproj) + project = Xcodeproj::Project.open(xcodeproj) + project.targets.each do |t| + t.build_configurations.each do |c| + next if c.build_settings["VERSIONING_SYSTEM"] == "apple-generic" + c.build_settings["VERSIONING_SYSTEM"] = "apple-generic" + c.build_settings["CURRENT_PROJECT_VERSION"] ||= "1" + end + end + project.save + report_success("Enabled Apple Generic Versioning in Xcode project") + end + + def ios_connect_api_key + Fastlane::Actions::AppStoreConnectApiKeyAction.run( + key_id: ENV["IOS_CONNECT_KEY_ID"], + issuer_id: ENV["IOS_CONNECT_ISSUER_ID"], + key_filepath: ENV["IOS_CONNECT_API_KEY_PATH"], + in_house: false, + ) + end + + # Increment the build number based on latest TestFlight build + def ios_increment_build_number(xcodeproj) + ios_ensure_generic_versioning(xcodeproj) + api_key = ios_connect_api_key + + latest = Fastlane::Actions::LatestTestflightBuildNumberAction.run( + api_key: api_key, + app_identifier: ENV["IOS_APP_IDENTIFIER"], + platform: "ios" + ) + + new_number = latest + 1 + Fastlane::Actions::IncrementBuildNumberAction.run( + build_number: new_number, + xcodeproj: xcodeproj + ) + report_success("Incremented build number to #{new_number} (previous #{latest})") + new_number + end + + # Decode certificate from ENV and import into keychain + def ios_dev_setup_certificate + data = ENV["IOS_DIST_CERT_BASE64"] + pass = ENV["IOS_P12_PASSWORD"] + report_error("Missing IOS_P12_PASSWORD") unless pass + report_error("Missing IOS_DIST_CERT_BASE64") unless data + tmp = Tempfile.new(["fastlane_local_cert", ".p12"]) + tmp.binmode + tmp.write(Base64.decode64(data)) + tmp.close + success = system("security import #{Shellwords.escape(tmp.path)} -P #{Shellwords.escape(pass)} -T /usr/bin/codesign") + report_error("Failed to import certificate into keychain") unless success + report_success("Certificate imported successfully into default keychain") + ensure + tmp&.unlink + end + + # Decode API key for local development + def ios_dev_setup_connect_api_key(path) + full = File.expand_path(path, File.dirname(__FILE__)) + ENV["IOS_CONNECT_API_KEY_PATH"] = full + if ENV["IOS_CONNECT_API_KEY_BASE64"] + FileUtils.mkdir_p(File.dirname(full)) + File.write(full, Base64.decode64(ENV["IOS_CONNECT_API_KEY_BASE64"])) + File.chmod(0600, full) + report_success("Connect API Key written to: #{full}") + end + File.realpath(full) + end + + # Decode and install provisioning profile + def ios_dev_setup_provisioning_profile(dir) + data = ENV["IOS_PROV_PROFILE_BASE64"] + report_error("Missing IOS_PROV_PROFILE_BASE64") unless data + decoded = Base64.decode64(data) + tmp_profile = Tempfile.new(["fastlane_local_profile", ".mobileprovision"]) + tmp_profile.binmode + tmp_profile.write(decoded) + tmp_profile.close + + tmp_plist = Tempfile.new(["fastlane_temp_plist", ".plist"]) + success = system("security cms -D -i #{Shellwords.escape(tmp_profile.path)} -o #{Shellwords.escape(tmp_plist.path)}") + report_error("Failed to decode provisioning profile") unless success + uuid = `/usr/libexec/PlistBuddy -c "Print :UUID" #{Shellwords.escape(tmp_plist.path)} 2>/dev/null`.strip + report_error("Failed to extract UUID from provisioning profile") if uuid.empty? + + target_dir = File.expand_path(dir) + FileUtils.mkdir_p(target_dir) + final_path = File.join(target_dir, "#{uuid}.mobileprovision") + FileUtils.cp(tmp_profile.path, final_path) + ENV["IOS_PROV_PROFILE_PATH"] = final_path + report_success("Provisioning profile installed successfully") + final_path + ensure + tmp_profile&.unlink + tmp_plist&.unlink + end + + # Ensure installed profile exists + def ios_verify_provisioning_profile + path = ENV["IOS_PROV_PROFILE_PATH"] + report_error("ENV['IOS_PROV_PROFILE_PATH'] is not set") if path.to_s.empty? + File.realpath(path) + report_success("iOS provisioning profile verified successfully at #{path}") + rescue Errno::ENOENT + report_error("Provisioning profile not found at: #{path}") + end + end + end +end diff --git a/app/fastlane/helpers/slack.rb b/app/fastlane/helpers/slack.rb new file mode 100644 index 000000000..56e202556 --- /dev/null +++ b/app/fastlane/helpers/slack.rb @@ -0,0 +1,102 @@ +module Fastlane + module Helpers + module Slack + # Upload a file to Slack using the files.upload API + def upload_file_to_slack(file_path:, channel_id:, initial_comment: nil, thread_ts: nil, title: nil) + slack_token = ENV["SLACK_API_TOKEN"] + report_error("Missing SLACK_API_TOKEN environment variable.", nil, "Slack Upload Failed") if slack_token.to_s.strip.empty? + report_error("File not found at path: #{file_path}", nil, "Slack Upload Failed") unless File.exist?(file_path) + + file_name = File.basename(file_path) + file_size = File.size(file_path) + file_title = title || file_name + + upload_url, file_id = request_upload_url(slack_token, file_name, file_size) + upload_file_content(upload_url, file_path, file_size) + final_info = complete_upload(slack_token, file_id, file_title, channel_id, initial_comment, thread_ts) + + report_success("Successfully uploaded and shared #{file_name} to Slack channel #{channel_id}") + final_info + rescue => e + report_error("Error during Slack upload process: #{e.message}", e.backtrace.join("\n"), "Slack Upload Failed") + false + end + + private + + def request_upload_url(slack_token, file_name, file_size) + upload_url = nil + file_id = nil + with_retry(max_retries: 3, delay: 5) do + uri = URI.parse("https://slack.com/api/files.getUploadURLExternal") + request = Net::HTTP::Post.new(uri) + request["Authorization"] = "Bearer #{slack_token}" + request.set_form_data(filename: file_name, length: file_size) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + response = http.request(request) + + # Handle rate limiting specifically + if response.code == "429" + raise "HTTP 429 Rate limit exceeded for Slack API" + end + + raise "Slack API failed: #{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess) + json = JSON.parse(response.body) + raise "Slack API Error: #{json["error"]}" unless json["ok"] + upload_url = json["upload_url"] + file_id = json["file_id"] + end + [upload_url, file_id] + end + + def upload_file_content(upload_url, file_path, file_size) + with_retry(max_retries: 3, delay: 5) do + upload_uri = URI.parse(upload_url) + upload_request = Net::HTTP::Post.new(upload_uri) + upload_request.body = File.binread(file_path) + upload_request["Content-Type"] = "application/octet-stream" + upload_request["Content-Length"] = file_size.to_s + upload_http = Net::HTTP.new(upload_uri.host, upload_uri.port) + upload_http.use_ssl = true + upload_response = upload_http.request(upload_request) + + # Handle rate limiting specifically + if upload_response.code == "429" + raise "HTTP 429 Rate limit exceeded for file upload" + end + + raise "File upload failed: #{upload_response.code} #{upload_response.message}" unless upload_response.is_a?(Net::HTTPOK) + end + end + + def complete_upload(slack_token, file_id, file_title, channel_id, initial_comment, thread_ts) + final_info = nil + with_retry(max_retries: 3, delay: 5) do + complete_uri = URI.parse("https://slack.com/api/files.completeUploadExternal") + complete_request = Net::HTTP::Post.new(complete_uri) + complete_request["Authorization"] = "Bearer #{slack_token}" + complete_request["Content-Type"] = "application/json; charset=utf-8" + payload = { files: [{ id: file_id, title: file_title }], channel_id: channel_id } + payload[:initial_comment] = initial_comment if initial_comment + payload[:thread_ts] = thread_ts if thread_ts + complete_request.body = payload.to_json + complete_http = Net::HTTP.new(complete_uri.host, complete_uri.port) + complete_http.use_ssl = true + complete_response = complete_http.request(complete_request) + + # Handle rate limiting specifically + if complete_response.code == "429" + raise "HTTP 429 Rate limit exceeded for Slack API" + end + + raise "Slack API failed: #{complete_response.code} #{complete_response.body}" unless complete_response.is_a?(Net::HTTPSuccess) + json = JSON.parse(complete_response.body) + raise "Slack API Error: #{json["error"]}" unless json["ok"] + final_info = json["files"]&.first + end + final_info + end + end + end +end diff --git a/app/fastlane/test/helpers_test.rb b/app/fastlane/test/helpers_test.rb new file mode 100644 index 000000000..0310803a2 --- /dev/null +++ b/app/fastlane/test/helpers_test.rb @@ -0,0 +1,342 @@ +require "minitest/autorun" +require_relative "../helpers" + +class HelpersTest < Minitest::Test + def setup + @gradle = Tempfile.new(["build", ".gradle"]) + @gradle.write("versionCode 5\n") + @gradle.close + Fastlane::Helpers::Android.set_permissions(true) + + # Store original environment for cleanup + @original_env = ENV.to_h + clear_test_env_vars + end + + def teardown + @gradle.unlink + + # Restore original environment + ENV.clear + ENV.update(@original_env) + end + + def test_android_increment_version_code + new_code = Fastlane::Helpers.android_increment_version_code(@gradle.path) + assert_equal 6, new_code + assert_includes File.read(@gradle.path), "versionCode 6" + end + + def test_should_upload_app + assert_respond_to Fastlane::Helpers, :should_upload_app + ENV.delete("CI") + ENV.delete("FORCE_UPLOAD_LOCAL_DEV") + ENV.delete("ACT") + ENV.delete("IS_PR") + assert_equal false, Fastlane::Helpers.should_upload_app("ios") + ENV["FORCE_UPLOAD_LOCAL_DEV"] = "true" + assert_equal true, Fastlane::Helpers.should_upload_app("ios") + ensure + ENV.delete("FORCE_UPLOAD_LOCAL_DEV") + end + + def test_should_upload_app_with_ci + ENV["CI"] = "true" + %w[FORCE_UPLOAD_LOCAL_DEV ACT IS_PR].each { |v| ENV.delete(v) } + assert_equal true, Fastlane::Helpers.should_upload_app("ios") + ensure + ENV.delete("CI") + end + + def test_should_upload_app_with_act_or_is_pr + %w[ACT IS_PR].each do |flag| + ENV[flag] = "true" + %w[CI FORCE_UPLOAD_LOCAL_DEV].each { |v| ENV.delete(v) } + assert_equal false, Fastlane::Helpers.should_upload_app("ios"), "#{flag} should block upload" + ENV.delete(flag) + end + end + + def test_should_upload_app_with_invalid_platform + %w[CI ACT IS_PR FORCE_UPLOAD_LOCAL_DEV].each { |v| ENV.delete(v) } + assert_equal false, Fastlane::Helpers.should_upload_app(nil) + end + + # Environment Detection Tests + def test_is_ci_environment_true_conditions + ENV["CI"] = "true" + ENV.delete("ACT") + assert_equal true, Fastlane::Helpers.is_ci_environment? + end + + def test_is_ci_environment_false_with_act + ENV["CI"] = "true" + ENV["ACT"] = "true" + assert_equal false, Fastlane::Helpers.is_ci_environment? + end + + def test_is_ci_environment_false_without_ci + ENV.delete("CI") + ENV.delete("ACT") + assert_equal false, Fastlane::Helpers.is_ci_environment? + end + + def test_is_ci_environment_false_with_ci_false + ENV["CI"] = "false" + ENV.delete("ACT") + assert_equal false, Fastlane::Helpers.is_ci_environment? + end + + # Android File Operations Tests + def test_android_create_keystore_success + test_data = "fake keystore binary data" + ENV["ANDROID_KEYSTORE"] = Base64.encode64(test_data) + temp_path = File.join(Dir.tmpdir, "test_keystore.jks") + + result = Fastlane::Helpers.android_create_keystore(temp_path) + + assert File.exist?(temp_path) + assert_equal test_data, File.read(temp_path) + assert_equal File.realpath(temp_path), result + ensure + File.delete(temp_path) if File.exist?(temp_path) + end + + def test_android_create_keystore_missing_env + ENV.delete("ANDROID_KEYSTORE") + result = Fastlane::Helpers.android_create_keystore("/tmp/test.jks") + assert_nil result + end + + def test_android_create_keystore_creates_directory + test_data = "keystore content" + ENV["ANDROID_KEYSTORE"] = Base64.encode64(test_data) + nested_path = File.join(Dir.tmpdir, "nested", "dir", "keystore.jks") + + result = Fastlane::Helpers.android_create_keystore(nested_path) + + assert File.exist?(nested_path) + assert_equal test_data, File.read(nested_path) + assert_equal File.realpath(nested_path), result + ensure + FileUtils.rm_rf(File.join(Dir.tmpdir, "nested")) if File.exist?(File.join(Dir.tmpdir, "nested")) + end + + def test_android_create_play_store_key_success + test_json = '{"type": "service_account", "project_id": "test"}' + ENV["ANDROID_PLAY_STORE_JSON_KEY_BASE64"] = Base64.encode64(test_json) + temp_path = File.join(Dir.tmpdir, "play_store_key.json") + + result = Fastlane::Helpers.android_create_play_store_key(temp_path) + + assert File.exist?(temp_path) + assert_equal test_json, File.read(temp_path) + assert_equal File.realpath(temp_path), result + ensure + File.delete(temp_path) if File.exist?(temp_path) + end + + def test_android_create_play_store_key_missing_env + ENV.delete("ANDROID_PLAY_STORE_JSON_KEY_BASE64") + result = Fastlane::Helpers.android_create_play_store_key("/tmp/test.json") + assert_nil result + end + + # Gradle Parsing Edge Cases + def test_android_increment_version_code_different_formats + test_cases = [ + "versionCode 999", + " versionCode 123 ", + "android {\n versionCode 42\n}", + "versionCode 0", + ] + + test_cases.each_with_index do |gradle_content, index| + # Create a new tempfile for each test case + gradle_file = Tempfile.new(["build_test", ".gradle"]) + gradle_file.write(gradle_content) + gradle_file.close + + current_version = gradle_content.match(/versionCode\s+(\d+)/)[1].to_i + expected_version = current_version + 1 + + new_code = Fastlane::Helpers.android_increment_version_code(gradle_file.path) + assert_equal expected_version, new_code + assert_includes File.read(gradle_file.path), "versionCode #{expected_version}" + + gradle_file.unlink + end + end + + def test_android_increment_version_code_no_permissions + Fastlane::Helpers::Android.set_permissions(false) + original_content = File.read(@gradle.path) + + # Should return current version, not increment + new_code = Fastlane::Helpers.android_increment_version_code(@gradle.path) + assert_equal 5, new_code # Current version, not incremented + assert_equal original_content, File.read(@gradle.path) # File unchanged + ensure + Fastlane::Helpers::Android.set_permissions(true) + end + + def test_android_increment_version_code_missing_file + assert_raises(RuntimeError) do + Fastlane::Helpers.android_increment_version_code("/nonexistent/build.gradle") + end + end + + # Android Version Code Verification Tests + # Note: These tests focus on the error handling improvements made to android_verify_version_code + # Full integration tests would require Play Store API mocking, which is beyond the scope of unit tests + + def test_android_verify_version_code_parsing_logic + # Test the parsing logic that we improved by creating a private method to extract version code + test_cases = [ + { content: "versionCode 123", expected: 123 }, + { content: " versionCode 456 ", expected: 456 }, + { content: "android {\n versionCode 789\n}", expected: 789 }, + { content: "versionCode 0", expected: 0 }, + ] + + test_cases.each do |test_case| + gradle_file = Tempfile.new(["build", ".gradle"]) + gradle_file.write(test_case[:content]) + gradle_file.close + + # Test the regex parsing that we improved + line = File.readlines(gradle_file.path).find { |l| l.include?("versionCode") } + refute_nil line, "Should find versionCode line" + + match = line.match(/versionCode\s+(\d+)/) + refute_nil match, "Should match versionCode pattern" + assert_equal test_case[:expected], match[1].to_i, "Should extract correct version code" + + gradle_file.unlink + end + end + + def test_android_verify_version_code_missing_version_code_line + # Test the error handling when versionCode is missing + gradle_file = Tempfile.new(["build", ".gradle"]) + gradle_file.write("applicationId 'com.example.app'\nminSdkVersion 21\n") + gradle_file.close + + # Test the logic that we improved + line = File.readlines(gradle_file.path).find { |l| l.include?("versionCode") } + assert_nil line, "Should not find versionCode line" + + gradle_file.unlink + end + + def test_android_verify_version_code_invalid_format + # Test the error handling when versionCode format is invalid + test_cases = [ + "versionCode 'invalid'", + "versionCode abc", + "versionCode", + "versionCode ", + ] + + test_cases.each do |content| + gradle_file = Tempfile.new(["build", ".gradle"]) + gradle_file.write(content) + gradle_file.close + + # Test the regex parsing that we improved + line = File.readlines(gradle_file.path).find { |l| l.include?("versionCode") } + refute_nil line, "Should find versionCode line" + + match = line.match(/versionCode\s+(\d+)/) + assert_nil match, "Should not match invalid versionCode pattern: #{content}" + + gradle_file.unlink + end + end + + # Retry Logic Tests + def test_with_retry_success_first_attempt + attempt_count = 0 + result = Fastlane::Helpers.with_retry(max_retries: 3, delay: 0) do + attempt_count += 1 + "success" + end + + assert_equal 1, attempt_count + assert_equal "success", result + end + + def test_with_retry_success_after_failures + attempt_count = 0 + result = Fastlane::Helpers.with_retry(max_retries: 3, delay: 0) do + attempt_count += 1 + raise "temporary failure" if attempt_count < 3 + "success" + end + + assert_equal 3, attempt_count + assert_equal "success", result + end + + def test_with_retry_max_retries_exceeded + attempt_count = 0 + assert_raises(FastlaneCore::Interface::FastlaneError) do + Fastlane::Helpers.with_retry(max_retries: 2, delay: 0) do + attempt_count += 1 + raise "persistent failure" + end + end + + assert_equal 2, attempt_count + end + + def test_with_retry_custom_parameters + attempt_count = 0 + + assert_raises(FastlaneCore::Interface::FastlaneError) do + Fastlane::Helpers.with_retry(max_retries: 1, delay: 0) do + attempt_count += 1 + raise "failure" + end + end + + assert_equal 1, attempt_count + end + + # Environment Variable Validation Logic + def test_verify_env_vars_all_present + ENV["TEST_VAR1"] = "value1" + ENV["TEST_VAR2"] = "value2" + + # Test the underlying logic that verify_env_vars uses + required_vars = ["TEST_VAR1", "TEST_VAR2"] + missing = required_vars.select { |var| ENV[var].to_s.strip.empty? } + + assert_empty missing + end + + def test_verify_env_vars_some_missing + ENV["PRESENT_VAR"] = "value" + ENV.delete("MISSING_VAR1") + ENV["EMPTY_VAR"] = "" + ENV["WHITESPACE_VAR"] = " " + + # Test the underlying logic that verify_env_vars uses + required_vars = ["PRESENT_VAR", "MISSING_VAR1", "EMPTY_VAR", "WHITESPACE_VAR"] + missing = required_vars.select { |var| ENV[var].to_s.strip.empty? } + + assert_equal ["MISSING_VAR1", "EMPTY_VAR", "WHITESPACE_VAR"], missing + end + + private + + def clear_test_env_vars + # Clean up environment variables that might affect tests + test_vars = %w[ + CI ACT FORCE_UPLOAD_LOCAL_DEV IS_PR + ANDROID_KEYSTORE ANDROID_PLAY_STORE_JSON_KEY_BASE64 + TEST_VAR1 TEST_VAR2 PRESENT_VAR MISSING_VAR1 EMPTY_VAR WHITESPACE_VAR + ] + test_vars.each { |var| ENV.delete(var) } + end +end diff --git a/app/ios/Self.xcodeproj/project.pbxproj b/app/ios/Self.xcodeproj/project.pbxproj index 1e012d112..8b0fa673c 100644 --- a/app/ios/Self.xcodeproj/project.pbxproj +++ b/app/ios/Self.xcodeproj/project.pbxproj @@ -407,7 +407,7 @@ CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassportDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 145; + CURRENT_PROJECT_VERSION = 147; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 5B29R5LYHQ; ENABLE_BITCODE = NO; @@ -548,7 +548,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassport.entitlements; - CURRENT_PROJECT_VERSION = 145; + CURRENT_PROJECT_VERSION = 147; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 5B29R5LYHQ; FRAMEWORK_SEARCH_PATHS = ( diff --git a/app/package.json b/app/package.json index d4e8ec292..ec3c90e8c 100644 --- a/app/package.json +++ b/app/package.json @@ -7,11 +7,6 @@ "analyze:android": "yarn reinstall && react-native-bundle-visualizer --platform android --dev", "analyze:ios": "yarn reinstall && react-native-bundle-visualizer --platform ios --dev", "android": "react-native run-android", - "android:build-apk": "yarn reinstall && cd ./android && ./gradlew clean assembleRelease && cd ..", - "android:build-debug": "yarn reinstall && cd ./android && yarn android:build-debug-bundle && ./gradlew clean assembleDebug && cd ..", - "android:build-debug-bundle": "yarn react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle --assets-dest android/app/src/main/res/", - "android:build-release": "yarn reinstall && cd ./android && ./gradlew clean bundleRelease && cd ..", - "android:fastlane-debug": "yarn reinstall && bundle exec fastlane --verbose android internal_test", "build:deps": "yarn workspaces foreach --from @selfxyz/mobile-app --topological --recursive run build", "bump-version:major": "npm version major && yarn sync-versions", "bump-version:minor": "npm version minor && yarn sync-versions", @@ -27,21 +22,21 @@ "clean:xcode-env-local": "rm -f ios/.xcode.env.local", "fmt": "prettier --check .", "fmt:fix": "prettier --write .", - "force-local-upload-deploy": "yarn force-local-upload-deploy:android && yarn force-local-upload-deploy:ios", - "force-local-upload-deploy:android": "yarn reinstall && FORCE_UPLOAD_LOCAL_DEV=true bundle exec fastlane android deploy --verbose", - "force-local-upload-deploy:ios": "yarn reinstall && FORCE_UPLOAD_LOCAL_DEV=true bundle exec fastlane ios deploy --verbose", - "force-local-upload-test": "yarn force-local-upload-test:android && yarn force-local-upload-test:ios", - "force-local-upload-test:android": "yarn reinstall && FORCE_UPLOAD_LOCAL_DEV=true bundle exec fastlane android internal_test --verbose", - "force-local-upload-test:ios": "yarn reinstall && FORCE_UPLOAD_LOCAL_DEV=true bundle exec fastlane ios internal_test --verbose", "format": "yarn nice", "ia": "yarn install-app", "install-app": "yarn install-app:setup && cd ios && bundle exec pod install && cd .. && yarn clean:xcode-env-local", - "install-app:deploy": "yarn install-app:setup && yarn clean:xcode-env-local", + "install-app:mobile-deploy": "yarn install-app:setup && yarn clean:xcode-env-local", "install-app:setup": "yarn install && yarn build:deps && cd ios && bundle install && cd ..", "ios": "react-native run-ios", "ios:fastlane-debug": "yarn reinstall && bundle exec fastlane --verbose ios internal_test", "lint": "eslint .", "lint:fix": "eslint --fix .", + "mobile-deploy": "node scripts/mobile-deploy-confirm.cjs both", + "mobile-deploy:android": "node scripts/mobile-deploy-confirm.cjs android", + "mobile-deploy:ios": "node scripts/mobile-deploy-confirm.cjs ios", + "mobile-local-deploy": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs both", + "mobile-local-deploy:android": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs android", + "mobile-local-deploy:ios": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs ios", "nice": "yarn lint:fix && yarn fmt:fix", "reinstall": "yarn clean && yarn install && yarn install-app", "setup": "yarn clean:build && yarn install && yarn build:deps && cd ios && bundle install && bundle exec pod install --repo-update && cd .. && yarn clean:xcode-env-local", @@ -50,6 +45,7 @@ "tag:release": "node scripts/tag.js release", "tag:remove": "node scripts/tag.js remove", "test": "jest --passWithNoTests", + "test:fastlane": "bundle exec ruby -Itest fastlane/test/helpers_test.rb", "types": "tsc --noEmit" }, "dependencies": { diff --git a/app/scripts/mobile-deploy-confirm.cjs b/app/scripts/mobile-deploy-confirm.cjs new file mode 100755 index 000000000..8289060c7 --- /dev/null +++ b/app/scripts/mobile-deploy-confirm.cjs @@ -0,0 +1,542 @@ +// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// Constants +const DEPLOYMENT_METHODS = { + GITHUB_RUNNER: 'github-runner', + LOCAL_FASTLANE: 'local-fastlane', +}; + +const PLATFORMS = { + IOS: 'ios', + ANDROID: 'android', + BOTH: 'both', +}; + +const SUPPORTED_PLATFORMS = Object.values(PLATFORMS); + +const FILE_PATHS = { + PACKAGE_JSON: '../package.json', + IOS_INFO_PLIST: '../ios/OpenPassport/Info.plist', + IOS_PROJECT_PBXPROJ: '../ios/Self.xcodeproj/project.pbxproj', + ANDROID_BUILD_GRADLE: '../android/app/build.gradle', +}; + +const CONSOLE_SYMBOLS = { + MOBILE: '๐Ÿ“ฑ', + PACKAGE: '๐Ÿ“ฆ', + ROCKET: '๐Ÿš€', + WARNING: 'โš ๏ธ', + SUCCESS: 'โœ…', + ERROR: 'โŒ', + APPLE: '๐ŸŽ', + ANDROID: '๐Ÿค–', + CLOUD: 'โ˜๏ธ', + LOCATION: '๐Ÿ“', + MEMO: '๐Ÿ“', + CHART: '๐Ÿ“Š', + BROOM: '๐Ÿงน', + REPEAT: '๐Ÿ”„', +}; + +const REGEX_PATTERNS = { + IOS_VERSION: + /CFBundleShortVersionString<\/key>\s*(.*?)<\/string>/, + IOS_BUILD: /CURRENT_PROJECT_VERSION = (\d+);/, + ANDROID_VERSION: /versionName\s+"(.+?)"/, + ANDROID_VERSION_CODE: /versionCode\s+(\d+)/, +}; + +// Utility Functions + +/** + * Safely reads a file and returns its content or null if failed + * @param {string} filePath - Path to the file to read + * @param {string} description - Description of the file for error messages + * @returns {string|null} File content or null if failed + */ +function safeReadFile(filePath, description) { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch (error) { + console.warn(`Warning: Could not read ${description} at ${filePath}`); + return null; + } +} + +/** + * Safely executes a command and returns its output + * @param {string} command - Command to execute + * @param {string} description - Description for error messages + * @returns {string|null} Command output or null if failed + */ +function safeExecSync(command, description) { + // Whitelist of allowed commands to prevent command injection + const allowedCommands = [ + 'git branch --show-current', + 'git status --porcelain', + ]; + + // Validate that the command is in the whitelist + if (!allowedCommands.includes(command)) { + console.warn( + `Warning: Command '${command}' is not allowed for security reasons`, + ); + return null; + } + + try { + return execSync(command, { encoding: 'utf8' }).trim(); + } catch (error) { + console.warn(`Warning: Could not ${description}`); + return null; + } +} + +/** + * Validates the provided platform argument + * @param {string} platform - Platform argument to validate + * @returns {boolean} True if valid, false otherwise + */ +function validatePlatform(platform) { + return platform && SUPPORTED_PLATFORMS.includes(platform); +} + +/** + * Displays usage information and exits + */ +function displayUsageAndExit() { + console.error('Usage: node mobile-deploy-confirm.cjs '); + console.error(''); + console.error('Recommended: Use yarn commands instead:'); + console.error( + ' yarn mobile-deploy # Deploy to both platforms (GitHub runner)', + ); + console.error( + ' yarn mobile-deploy:ios # Deploy to iOS only (GitHub runner)', + ); + console.error( + ' yarn mobile-deploy:android # Deploy to Android only (GitHub runner)', + ); + console.error( + ' yarn mobile-local-deploy # Deploy to both platforms (local fastlane)', + ); + console.error( + ' yarn mobile-local-deploy:ios # Deploy to iOS only (local fastlane)', + ); + console.error( + ' yarn mobile-local-deploy:android # Deploy to Android only (local fastlane)', + ); + console.error(''); + console.error('Direct script usage:'); + console.error(' node mobile-deploy-confirm.cjs ios'); + console.error(' node mobile-deploy-confirm.cjs android'); + console.error(' node mobile-deploy-confirm.cjs both'); + console.error(''); + console.error('Environment Variables:'); + console.error( + ' FORCE_UPLOAD_LOCAL_DEV=true Use local fastlane instead of GitHub runner', + ); + console.error( + ' IOS_PROJECT_PBXPROJ_PATH Override iOS project.pbxproj path', + ); + process.exit(1); +} + +// Core Functions + +/** + * Determines the deployment method based on environment variables + * @returns {'github-runner' | 'local-fastlane'} The deployment method to use + */ +function getDeploymentMethod() { + // Check if running in GitHub Actions + if (process.env.GITHUB_ACTIONS === 'true') { + return DEPLOYMENT_METHODS.GITHUB_RUNNER; + } + + // Check if force upload is explicitly set for local development + if (process.env.FORCE_UPLOAD_LOCAL_DEV === 'true') { + return DEPLOYMENT_METHODS.LOCAL_FASTLANE; + } + + // Default to GitHub runner (safer default) + // Users must explicitly set FORCE_UPLOAD_LOCAL_DEV=true to use local fastlane + return DEPLOYMENT_METHODS.GITHUB_RUNNER; +} + +/** + * Reads the main version from package.json + * @returns {string} The main version number + */ +function getMainVersion() { + const packageJsonPath = path.join(__dirname, FILE_PATHS.PACKAGE_JSON); + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return packageJson.version || 'Unknown'; + } catch (error) { + console.warn(`Warning: Could not parse package.json: ${error.message}`); + return 'Unknown'; + } +} + +/** + * Reads iOS version information from Info.plist and project.pbxproj + * @returns {Object} iOS version information + */ +function getIOSVersion() { + const infoPlistPath = path.join(__dirname, FILE_PATHS.IOS_INFO_PLIST); + const infoPlist = safeReadFile(infoPlistPath, 'iOS Info.plist'); + + if (!infoPlist) { + return { version: 'Unknown', build: 'Unknown' }; + } + + const iosVersionMatch = infoPlist.match(REGEX_PATTERNS.IOS_VERSION); + const version = iosVersionMatch ? iosVersionMatch[1] : 'Unknown'; + + // Extract build number from project.pbxproj + // Allow iOS project path to be overridden by environment variable + const iosProjectPath = + process.env.IOS_PROJECT_PBXPROJ_PATH || FILE_PATHS.IOS_PROJECT_PBXPROJ; + const projectPath = path.join(__dirname, iosProjectPath); + const projectFile = safeReadFile(projectPath, 'iOS project.pbxproj'); + + let build = 'Unknown'; + if (projectFile) { + const buildMatch = projectFile.match(REGEX_PATTERNS.IOS_BUILD); + build = buildMatch ? buildMatch[1] : 'Unknown'; + } + + return { version, build }; +} + +/** + * Reads Android version information from build.gradle + * @returns {Object} Android version information + */ +function getAndroidVersion() { + const buildGradlePath = path.join(__dirname, FILE_PATHS.ANDROID_BUILD_GRADLE); + const buildGradle = safeReadFile(buildGradlePath, 'Android build.gradle'); + + if (!buildGradle) { + return { version: 'Unknown', versionCode: 'Unknown' }; + } + + const androidVersionMatch = buildGradle.match(REGEX_PATTERNS.ANDROID_VERSION); + const androidVersionCodeMatch = buildGradle.match( + REGEX_PATTERNS.ANDROID_VERSION_CODE, + ); + + return { + version: androidVersionMatch ? androidVersionMatch[1] : 'Unknown', + versionCode: androidVersionCodeMatch + ? androidVersionCodeMatch[1] + : 'Unknown', + }; +} + +/** + * Reads version information from package.json, iOS Info.plist, and Android build.gradle + * @returns {Object} Object containing version information for all platforms + */ +function getCurrentVersions() { + return { + main: getMainVersion(), + ios: getIOSVersion(), + android: getAndroidVersion(), + }; +} + +// Git Operations + +/** + * Gets the current git branch name + * @returns {string|null} Current branch name or null if failed + */ +function getCurrentBranch() { + return safeExecSync( + 'git branch --show-current', + 'determine current git branch', + ); +} + +/** + * Checks if there are uncommitted changes + * @returns {boolean} True if there are uncommitted changes + */ +function hasUncommittedChanges() { + const gitStatus = safeExecSync('git status --porcelain', 'check git status'); + return gitStatus && gitStatus.trim().length > 0; +} + +// Display Functions + +/** + * Displays the header and platform information + * @param {string} platform - Target platform + * @param {Object} versions - Version information object + */ +function displayDeploymentHeader(platform, versions) { + console.log(`\n${CONSOLE_SYMBOLS.MOBILE} Mobile App Deployment Confirmation`); + console.log('====================================='); + console.log(`${CONSOLE_SYMBOLS.ROCKET} Platform: ${platform.toUpperCase()}`); +} + +/** + * Displays deployment method information + * @param {string} deploymentMethod - The deployment method to use + */ +function displayDeploymentMethod(deploymentMethod) { + if (deploymentMethod === DEPLOYMENT_METHODS.LOCAL_FASTLANE) { + console.log( + `${CONSOLE_SYMBOLS.LOCATION} Deployment: Local fastlane upload`, + ); + } else { + console.log(`${CONSOLE_SYMBOLS.CLOUD} Deployment: GitHub Actions workflow`); + } +} + +/** + * Displays platform-specific version information + * @param {string} platform - Target platform + * @param {Object} versions - Version information object + */ +function displayPlatformVersions(platform, versions) { + console.log(`${CONSOLE_SYMBOLS.PACKAGE} Main Version: ${versions.main}`); + + if (platform === PLATFORMS.IOS || platform === PLATFORMS.BOTH) { + console.log( + `${CONSOLE_SYMBOLS.APPLE} iOS Version: ${versions.ios.version}`, + ); + console.log(`${CONSOLE_SYMBOLS.APPLE} iOS Build: ${versions.ios.build}`); + } + + if (platform === PLATFORMS.ANDROID || platform === PLATFORMS.BOTH) { + console.log( + `${CONSOLE_SYMBOLS.ANDROID} Android Version: ${versions.android.version}`, + ); + console.log( + `${CONSOLE_SYMBOLS.ANDROID} Android Version Code: ${versions.android.versionCode}`, + ); + } +} + +/** + * Displays warnings and git status information + */ +function displayWarningsAndGitStatus() { + const currentBranch = getCurrentBranch(); + const hasUncommitted = hasUncommittedChanges(); + + console.log(`\n${CONSOLE_SYMBOLS.WARNING} Important Notes:`); + console.log( + 'โ€ข Deploys to internal testing (TestFlight/Google Play Internal)', + ); + if (currentBranch) { + console.log(`โ€ข Current branch: ${currentBranch}`); + } + if (hasUncommitted) { + console.log('โ€ข You have uncommitted changes - consider committing first'); + } +} + +/** + * Displays all confirmation information + * @param {string} platform - Target platform + * @param {Object} versions - Version information object + * @param {string} deploymentMethod - The deployment method to use + */ +function displayFullConfirmation(platform, versions, deploymentMethod) { + displayDeploymentHeader(platform, versions); + displayDeploymentMethod(deploymentMethod); + displayPlatformVersions(platform, versions); + displayWarningsAndGitStatus(); +} + +/** + * Prompts the user for confirmation + * @returns {Promise} True if user confirms, false otherwise + */ +function promptConfirmation() { + const readline = require('readline').createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise(resolve => { + readline.question('\nDo you want to proceed? (y/N): ', answer => { + readline.close(); + // Trim whitespace and normalize to lowercase for robust comparison + const normalizedAnswer = answer.trim().toLowerCase(); + resolve(normalizedAnswer === 'y' || normalizedAnswer === 'yes'); + }); + }); +} + +// Deployment Functions + +/** + * Performs yarn reinstall to ensure clean dependencies + */ +function performYarnReinstall() { + console.log( + `\n${CONSOLE_SYMBOLS.BROOM} Performing yarn reinstall to ensure clean dependencies...`, + ); + execSync('yarn reinstall', { + stdio: 'inherit', + cwd: path.join(__dirname, '..'), + }); + console.log( + `${CONSOLE_SYMBOLS.SUCCESS} Yarn reinstall completed successfully!`, + ); +} + +/** + * Gets the fastlane commands for the specified platform + * @param {string} platform - Target platform + * @returns {string[]} Array of fastlane commands to execute + */ +function getFastlaneCommands(platform) { + const commands = []; + + if (platform === PLATFORMS.IOS || platform === PLATFORMS.BOTH) { + commands.push('cd .. && bundle exec fastlane ios internal_test'); + } + + if (platform === PLATFORMS.ANDROID || platform === PLATFORMS.BOTH) { + commands.push('cd .. && bundle exec fastlane android internal_test'); + } + + return commands; +} + +/** + * Executes local fastlane deployment + * @param {string} platform - Target platform + */ +async function executeLocalFastlaneDeployment(platform) { + console.log( + `\n${CONSOLE_SYMBOLS.ROCKET} Starting local fastlane deployment...`, + ); + + try { + performYarnReinstall(); + + const commands = getFastlaneCommands(platform); + + // Create environment with FORCE_UPLOAD_LOCAL_DEV set for child processes + const envWithForceUpload = { + ...process.env, + FORCE_UPLOAD_LOCAL_DEV: 'true', + }; + + for (const command of commands) { + console.log(`\n${CONSOLE_SYMBOLS.REPEAT} Running: ${command}`); + execSync(command, { + stdio: 'inherit', + cwd: __dirname, + env: envWithForceUpload, + }); + } + + console.log( + `${CONSOLE_SYMBOLS.SUCCESS} Local fastlane deployment completed successfully!`, + ); + console.log( + `${CONSOLE_SYMBOLS.MOBILE} Check your app store dashboards for the new builds.`, + ); + } catch (error) { + console.error( + `${CONSOLE_SYMBOLS.ERROR} Local fastlane deployment failed:`, + error.message, + ); + process.exit(1); + } +} + +/** + * Executes GitHub runner deployment + * @param {string} platform - Target platform + */ +async function executeGithubRunnerDeployment(platform) { + console.log( + `\n${CONSOLE_SYMBOLS.ROCKET} Starting GitHub runner deployment...`, + ); + + // Safely get the current branch name to avoid command injection + const currentBranch = getCurrentBranch(); + if (!currentBranch) { + console.error( + `${CONSOLE_SYMBOLS.ERROR} Could not determine current git branch`, + ); + process.exit(1); + } + + const command = `gh workflow run mobile-deploy.yml --ref ${currentBranch} -f platform=${platform}`; + + try { + execSync(command, { stdio: 'inherit' }); + console.log( + `${CONSOLE_SYMBOLS.SUCCESS} GitHub workflow triggered successfully!`, + ); + console.log( + `${CONSOLE_SYMBOLS.CHART} Check GitHub Actions for build progress.`, + ); + } catch (error) { + console.error( + `${CONSOLE_SYMBOLS.ERROR} Failed to trigger GitHub workflow:`, + error.message, + ); + process.exit(1); + } +} + +/** + * Executes the deployment based on the specified method + * @param {string} platform - Target platform + * @param {string} deploymentMethod - The deployment method to use + */ +async function executeDeployment(platform, deploymentMethod) { + if (deploymentMethod === DEPLOYMENT_METHODS.LOCAL_FASTLANE) { + await executeLocalFastlaneDeployment(platform); + } else { + await executeGithubRunnerDeployment(platform); + } +} + +// Main Function + +/** + * Main function that orchestrates the deployment confirmation process + */ +async function main() { + const platform = process.argv[2]; + + if (!validatePlatform(platform)) { + displayUsageAndExit(); + } + + const deploymentMethod = getDeploymentMethod(); + const versions = getCurrentVersions(); + + displayFullConfirmation(platform, versions, deploymentMethod); + + const confirmed = await promptConfirmation(); + + if (confirmed) { + await executeDeployment(platform, deploymentMethod); + } else { + console.log(`\n${CONSOLE_SYMBOLS.ERROR} Deployment cancelled.`); + process.exit(0); + } +} + +// Execute main function +main().catch(error => { + console.error(`${CONSOLE_SYMBOLS.ERROR} Error:`, error.message); + process.exit(1); +}); diff --git a/app/scripts/tests/mobile-deploy-confirm.test.cjs b/app/scripts/tests/mobile-deploy-confirm.test.cjs new file mode 100644 index 000000000..be380bbf9 --- /dev/null +++ b/app/scripts/tests/mobile-deploy-confirm.test.cjs @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 + +const fs = require('fs'); +const path = require('path'); +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +const MOCK_IOS_INFO_PLIST = ` + + + + CFBundleShortVersionString + 1.2.3 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + +`; + +const MOCK_IOS_PROJECT_FILE = `// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + buildSettings = { + CURRENT_PROJECT_VERSION = 456; + MARKETING_VERSION = 1.2.3; + }; + }; + rootObject = 13B07F961A680F5B00A75B9A; +}`; + +const MOCK_ANDROID_BUILD_GRADLE = `android { + compileSdkVersion rootProject.ext.compileSdkVersion + + defaultConfig { + applicationId "com.example.testapp" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 789 + versionName "1.2.3" + } +}`; + +// Import the functions we want to test +// Since the original file doesn't export functions, we'll need to extract them +const REGEX_PATTERNS = { + IOS_VERSION: + /CFBundleShortVersionString<\/key>\s*(.*?)<\/string>/, + IOS_BUILD: /CURRENT_PROJECT_VERSION = (\d+);/, + ANDROID_VERSION: /versionName\s+"(.+?)"/, + ANDROID_VERSION_CODE: /versionCode\s+(\d+)/, +}; + +// Test helper functions +function extractIOSVersion(infoPlistContent) { + if (!infoPlistContent) return 'Unknown'; + const match = infoPlistContent.match(REGEX_PATTERNS.IOS_VERSION); + return match ? match[1] : 'Unknown'; +} + +function extractIOSBuild(projectFileContent) { + if (!projectFileContent) return 'Unknown'; + const match = projectFileContent.match(REGEX_PATTERNS.IOS_BUILD); + return match ? match[1] : 'Unknown'; +} + +function extractAndroidVersion(buildGradleContent) { + if (!buildGradleContent) return 'Unknown'; + const match = buildGradleContent.match(REGEX_PATTERNS.ANDROID_VERSION); + return match ? match[1] : 'Unknown'; +} + +function extractAndroidVersionCode(buildGradleContent) { + if (!buildGradleContent) return 'Unknown'; + const match = buildGradleContent.match(REGEX_PATTERNS.ANDROID_VERSION_CODE); + return match ? match[1] : 'Unknown'; +} + +// Tests +describe('Mobile Deploy Confirm - File Parsing', () => { + describe('iOS Version Extraction', () => { + it('should extract iOS version from Info.plist', () => { + const version = extractIOSVersion(MOCK_IOS_INFO_PLIST); + assert.strictEqual(version, '1.2.3'); + }); + + it('should return "Unknown" for malformed Info.plist', () => { + const malformedPlist = + 'InvalidKeyvalue'; + const version = extractIOSVersion(malformedPlist); + assert.strictEqual(version, 'Unknown'); + }); + + it('should extract iOS build number from project.pbxproj', () => { + const build = extractIOSBuild(MOCK_IOS_PROJECT_FILE); + assert.strictEqual(build, '456'); + }); + + it('should return "Unknown" for malformed project file', () => { + const malformedProject = 'invalid project file content'; + const build = extractIOSBuild(malformedProject); + assert.strictEqual(build, 'Unknown'); + }); + + it('should handle multiple CURRENT_PROJECT_VERSION entries', () => { + const multipleEntries = ` + CURRENT_PROJECT_VERSION = 123; + CURRENT_PROJECT_VERSION = 456; + `; + const build = extractIOSBuild(multipleEntries); + assert.strictEqual(build, '123'); // Should match the first occurrence + }); + }); + + describe('Android Version Extraction', () => { + it('should extract Android version from build.gradle', () => { + const version = extractAndroidVersion(MOCK_ANDROID_BUILD_GRADLE); + assert.strictEqual(version, '1.2.3'); + }); + + it('should extract Android version code from build.gradle', () => { + const versionCode = extractAndroidVersionCode(MOCK_ANDROID_BUILD_GRADLE); + assert.strictEqual(versionCode, '789'); + }); + + it('should return "Unknown" for malformed build.gradle', () => { + const malformedGradle = 'invalid gradle content'; + const version = extractAndroidVersion(malformedGradle); + const versionCode = extractAndroidVersionCode(malformedGradle); + assert.strictEqual(version, 'Unknown'); + assert.strictEqual(versionCode, 'Unknown'); + }); + + it('should handle different versionName formats', () => { + const gradleWithSingleQuotes = `versionName '2.0.0'`; + const gradleWithDoubleQuotes = `versionName "2.0.0"`; + const gradleWithSpacing = `versionName "2.0.0"`; + + // Current regex only handles double quotes + assert.strictEqual( + extractAndroidVersion(gradleWithDoubleQuotes), + '2.0.0', + ); + assert.strictEqual(extractAndroidVersion(gradleWithSpacing), '2.0.0'); + assert.strictEqual( + extractAndroidVersion(gradleWithSingleQuotes), + 'Unknown', + ); + }); + + it('should handle different versionCode formats', () => { + const gradleWithSpacing = `versionCode 123`; + const gradleWithTabs = `versionCode\t456`; + + assert.strictEqual(extractAndroidVersionCode(gradleWithSpacing), '123'); + assert.strictEqual(extractAndroidVersionCode(gradleWithTabs), '456'); + }); + }); + + describe('Real File Integration Tests', () => { + it('should parse actual iOS Info.plist if it exists', () => { + const infoPlistPath = path.join( + __dirname, + '../ios/OpenPassport/Info.plist', + ); + + if (fs.existsSync(infoPlistPath)) { + const content = fs.readFileSync(infoPlistPath, 'utf8'); + const version = extractIOSVersion(content); + + // Should either be a valid version or 'Unknown' + assert.strictEqual(typeof version, 'string'); + assert.ok(version.length > 0); + } else { + console.warn('iOS Info.plist not found - skipping real file test'); + } + }); + + it('should parse actual iOS project.pbxproj if it exists', () => { + const projectPath = path.join( + __dirname, + '../ios/Self.xcodeproj/project.pbxproj', + ); + + if (fs.existsSync(projectPath)) { + const content = fs.readFileSync(projectPath, 'utf8'); + const build = extractIOSBuild(content); + + // Should either be a valid build number or 'Unknown' + assert.strictEqual(typeof build, 'string'); + assert.ok(build.length > 0); + + // If it's a number, it should be positive + if (build !== 'Unknown') { + assert.ok(parseInt(build, 10) > 0); + } + } else { + console.warn('iOS project.pbxproj not found - skipping real file test'); + } + }); + + it('should parse actual Android build.gradle if it exists', () => { + const buildGradlePath = path.join( + __dirname, + '../android/app/build.gradle', + ); + + if (fs.existsSync(buildGradlePath)) { + const content = fs.readFileSync(buildGradlePath, 'utf8'); + const version = extractAndroidVersion(content); + const versionCode = extractAndroidVersionCode(content); + + // Should either be valid values or 'Unknown' + assert.strictEqual(typeof version, 'string'); + assert.strictEqual(typeof versionCode, 'string'); + assert.ok(version.length > 0); + assert.ok(versionCode.length > 0); + + // If versionCode is a number, it should be positive + if (versionCode !== 'Unknown') { + assert.ok(parseInt(versionCode, 10) > 0); + } + } else { + console.warn( + 'Android build.gradle not found - skipping real file test', + ); + } + }); + + it('should parse actual package.json if it exists', () => { + const packageJsonPath = path.join(__dirname, '../package.json'); + + if (fs.existsSync(packageJsonPath)) { + const content = fs.readFileSync(packageJsonPath, 'utf8'); + const packageJson = JSON.parse(content); + + assert.ok(Object.hasOwn(packageJson, 'version')); + assert.strictEqual(typeof packageJson.version, 'string'); + assert.ok(packageJson.version.match(/^\d+\.\d+\.\d+/)); // Basic semver check + } else { + console.warn('package.json not found - skipping real file test'); + } + }); + }); + + describe('Edge Cases and Error Handling', () => { + it('should handle empty file contents', () => { + assert.strictEqual(extractIOSVersion(''), 'Unknown'); + assert.strictEqual(extractIOSBuild(''), 'Unknown'); + assert.strictEqual(extractAndroidVersion(''), 'Unknown'); + assert.strictEqual(extractAndroidVersionCode(''), 'Unknown'); + }); + + it('should handle null/undefined inputs', () => { + assert.strictEqual(extractIOSVersion(null), 'Unknown'); + assert.strictEqual(extractIOSBuild(undefined), 'Unknown'); + assert.strictEqual(extractAndroidVersion(null), 'Unknown'); + assert.strictEqual(extractAndroidVersionCode(undefined), 'Unknown'); + }); + + it('should handle very large version numbers', () => { + const largeVersionPlist = MOCK_IOS_INFO_PLIST.replace( + '1.2.3', + '999.999.999', + ); + const largeVersionGradle = MOCK_ANDROID_BUILD_GRADLE.replace( + '1.2.3', + '999.999.999', + ); + const largeBuildProject = MOCK_IOS_PROJECT_FILE.replace('456', '999999'); + const largeVersionCodeGradle = MOCK_ANDROID_BUILD_GRADLE.replace( + '789', + '999999', + ); + + assert.strictEqual(extractIOSVersion(largeVersionPlist), '999.999.999'); + assert.strictEqual( + extractAndroidVersion(largeVersionGradle), + '999.999.999', + ); + assert.strictEqual(extractIOSBuild(largeBuildProject), '999999'); + assert.strictEqual( + extractAndroidVersionCode(largeVersionCodeGradle), + '999999', + ); + }); + + it('should handle version strings with special characters', () => { + const specialVersionPlist = MOCK_IOS_INFO_PLIST.replace( + '1.2.3', + '1.2.3-beta.1', + ); + const specialVersionGradle = MOCK_ANDROID_BUILD_GRADLE.replace( + '1.2.3', + '1.2.3-beta.1', + ); + + assert.strictEqual( + extractIOSVersion(specialVersionPlist), + '1.2.3-beta.1', + ); + assert.strictEqual( + extractAndroidVersion(specialVersionGradle), + '1.2.3-beta.1', + ); + }); + }); +}); + +console.log( + 'โœ… All tests defined. Run with: node --test mobile-deploy-confirm.test.cjs', +); diff --git a/package.json b/package.json index 40b78a4eb..bc616baec 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "postinstall": "patch-package", "lint": "yarn workspaces foreach --parallel -i --all --exclude self-workspace-root run lint", "prepare": "husky", - "sort-packages": "find . -name 'package.json' -not -path './node_modules/*' -not -path './*/node_modules/*' | xargs npx sort-package-json", + "sort-package-jsons": "find . -name 'package.json' -not -path './node_modules/*' -not -path './*/node_modules/*' | xargs npx sort-package-json", "types": "yarn workspaces foreach --topological-dev --parallel --exclude @selfxyz/contracts -i --all run types " }, "devDependencies": {