mirror of
https://github.com/selfxyz/self.git
synced 2026-01-09 14:48:06 -05:00
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:
2
.github/actions/get-version/action.yml
vendored
2
.github/actions/get-version/action.yml
vendored
@@ -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
|
||||
|
||||
2
.github/actions/mobile-setup/action.yml
vendored
2
.github/actions/mobile-setup/action.yml
vendored
@@ -54,4 +54,4 @@ runs:
|
||||
corepack enable
|
||||
yarn set version 4.6.0
|
||||
yarn install
|
||||
yarn install-app:deploy
|
||||
yarn install-app:mobile-deploy
|
||||
|
||||
113
.github/workflows/mobile-deploy.yml
vendored
113
.github/workflows/mobile-deploy.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -19,4 +19,4 @@ IOS_TEAM_ID=
|
||||
IOS_TEAM_NAME=
|
||||
IOS_TESTFLIGHT_GROUPS=
|
||||
SLACK_CHANNEL_ID=
|
||||
SLACK_BOT_TOKEN=
|
||||
SLACK_API_TOKEN=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
----
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
90
app/fastlane/helpers/android.rb
Normal file
90
app/fastlane/helpers/android.rb
Normal 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
|
||||
89
app/fastlane/helpers/common.rb
Normal file
89
app/fastlane/helpers/common.rb
Normal 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
148
app/fastlane/helpers/ios.rb
Normal 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
|
||||
102
app/fastlane/helpers/slack.rb
Normal file
102
app/fastlane/helpers/slack.rb
Normal 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
|
||||
342
app/fastlane/test/helpers_test.rb
Normal file
342
app/fastlane/test/helpers_test.rb
Normal 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
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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": {
|
||||
|
||||
542
app/scripts/mobile-deploy-confirm.cjs
Executable file
542
app/scripts/mobile-deploy-confirm.cjs
Executable 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);
|
||||
});
|
||||
314
app/scripts/tests/mobile-deploy-confirm.test.cjs
Normal file
314
app/scripts/tests/mobile-deploy-confirm.test.cjs
Normal 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',
|
||||
);
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user