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
This commit is contained in:
Justin Hernandez
2025-07-03 22:32:14 -07:00
committed by GitHub
parent 601cad5b48
commit b841b19d96
22 changed files with 2195 additions and 949 deletions

View File

@@ -14,4 +14,4 @@ runs:
shell: bash
run: |
VERSION=$(node -p "require('${{ inputs.app_path }}/package.json').version")
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_ENV

View File

@@ -54,4 +54,4 @@ runs:
corepack enable
yarn set version 4.6.0
yarn install
yarn install-app:deploy
yarn install-app:mobile-deploy

View File

@@ -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 }}

View File

@@ -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',
},
},
],

View File

@@ -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"

View File

@@ -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)

View File

@@ -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"]

View File

@@ -19,4 +19,4 @@ IOS_TEAM_ID=
IOS_TEAM_NAME=
IOS_TESTFLIGHT_GROUPS=
SLACK_CHANNEL_ID=
SLACK_BOT_TOKEN=
SLACK_API_TOKEN=

View File

@@ -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

View File

@@ -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)

View File

@@ -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
----

View File

@@ -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

View File

@@ -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 <number>",
"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 <number>" 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

View File

@@ -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

148
app/fastlane/helpers/ios.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 = (

View File

@@ -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": {

View File

@@ -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:
/<key>CFBundleShortVersionString<\/key>\s*<string>(.*?)<\/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 <ios|android|both>');
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<boolean>} 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);
});

View File

@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string>1.2.3</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
</dict>
</plist>`;
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:
/<key>CFBundleShortVersionString<\/key>\s*<string>(.*?)<\/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 =
'<dict><key>InvalidKey</key><string>value</string></dict>';
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',
);

View File

@@ -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": {