diff --git a/.actrc b/.actrc new file mode 100644 index 000000000..ff718c5a1 --- /dev/null +++ b/.actrc @@ -0,0 +1,3 @@ +--container-architecture linux/amd64 +--platform macos-latest=catthehacker/ubuntu:runner-latest +--secret-file ./app/fastlane/.env.secrets \ No newline at end of file diff --git a/.github/actions/get-version/action.yml b/.github/actions/get-version/action.yml new file mode 100644 index 000000000..7cba6b139 --- /dev/null +++ b/.github/actions/get-version/action.yml @@ -0,0 +1,17 @@ +name: Get Version from package.json + +description: "Gets the version from package.json and sets it as an environment variable" + +inputs: + app_path: + description: "Path to the app directory" + required: true + +runs: + using: "composite" + steps: + - name: Get version from package.json + shell: bash + run: | + VERSION=$(node -p "require('${{ inputs.app_path }}/package.json').version") + echo "VERSION=$VERSION" >> $GITHUB_ENV \ No newline at end of file diff --git a/.github/actions/mobile-setup/action.yml b/.github/actions/mobile-setup/action.yml new file mode 100644 index 000000000..c7228f4ed --- /dev/null +++ b/.github/actions/mobile-setup/action.yml @@ -0,0 +1,56 @@ +name: Setup Mobile Environment + +description: "Sets up the environment for mobile app builds" + +inputs: + app_path: + description: "Path to the app directory" + required: true + node_version: + description: "Node version" + required: true + ruby_version: + description: "Ruby version" + required: true + workspace: + description: "Workspace directory path" + required: true + +runs: + using: "composite" + steps: + - name: Install locales and dialog for local development + if: ${{ env.ACT }} + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y locales dialog unzip + + # for fastlane + - name: Install locales and dialog + if: runner.os != 'macOS' + shell: bash + run: | + sudo locale-gen en_US.UTF-8 + sudo update-locale LANG=en_US.UTF-8 + + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Ruby environment + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ inputs.ruby_version }} + + - name: Setup Node.js environment + uses: actions/setup-node@v3 + with: + node-version: ${{ inputs.node_version }} + + - name: Install app dependencies + shell: bash + run: | + cd ${{ inputs.app_path }} + corepack enable + yarn install + yarn install-app:deploy diff --git a/.github/actions/push-changes/action.yml b/.github/actions/push-changes/action.yml new file mode 100644 index 000000000..c9afa32af --- /dev/null +++ b/.github/actions/push-changes/action.yml @@ -0,0 +1,87 @@ +name: Push Build Version Changes + +description: "Commits and pushes build version changes for mobile platforms" + +inputs: + commit_message: + description: "Commit message" + required: true + commit_paths: + description: "Space-separated list of paths to check for changes (e.g. 'ios/file.txt android/another/file.xml')" + required: true + +runs: + using: "composite" + steps: + - name: Configure Git + shell: bash + run: | + set -e + git config --global user.email "action@github.com" + git config --global user.name "Self GitHub Actions" + + - name: Commit Changes + shell: bash + run: | + set -e + set -x + + # Restore the logic for checking specific paths existence + commit_paths_input="${{ inputs.commit_paths }}" + paths_to_commit="" + + for path in $commit_paths_input; do + if [ ! -e "$path" ]; then + echo "Error: Path $path does not exist" + exit 1 + fi + paths_to_commit="$paths_to_commit $path" + done + + if [ -z "$paths_to_commit" ]; then + echo "No valid paths provided." + exit 1 + fi + + # Remove leading space if present + paths_to_commit=$(echo "$paths_to_commit" | sed 's/^ *//') + + # Stage ONLY the specified paths + git add $paths_to_commit + + # Check if there are staged changes ONLY in the specified paths + if git diff --staged --quiet -- $paths_to_commit; then + echo "No changes to commit in the specified paths: $paths_to_commit" + else + echo "Changes to be committed in paths: $paths_to_commit" + # Show the staged diff for the specified paths + git diff --cached -- $paths_to_commit + git commit -m "chore: ${{ inputs.commit_message }} [github action]" + fi + + - name: Push Changes + shell: bash + run: | + set -e + set -x + + if git rev-parse --verify HEAD >/dev/null 2>&1; then + if [[ ${{ github.ref }} == refs/pull/* ]]; then + CURRENT_BRANCH=${{ github.head_ref }} + else + CURRENT_BRANCH=$(echo ${{ github.ref }} | sed 's|refs/heads/||') + fi + + echo "Pushing changes to branch: $CURRENT_BRANCH" + # Add --autostash to handle potential unstaged changes gracefully + git pull --rebase --autostash origin $CURRENT_BRANCH || { + echo "Failed to pull from $CURRENT_BRANCH" + exit 1 + } + git push origin HEAD:$CURRENT_BRANCH || { + echo "Failed to push to $CURRENT_BRANCH" + exit 1 + } + else + echo "No new commits to push" + fi diff --git a/.github/workflows/mobile-deploy.yml b/.github/workflows/mobile-deploy.yml new file mode 100644 index 000000000..c96b0e9ab --- /dev/null +++ b/.github/workflows/mobile-deploy.yml @@ -0,0 +1,486 @@ +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 + JAVA_VERSION: 17 + ANDROID_API_LEVEL: 35 + ANDROID_NDK_VERSION: 26.1.10909125 + + # Path configuration + WORKSPACE: ${{ github.workspace }} + APP_PATH: ${{ github.workspace }}/app + + # Certificate/keystore paths + ANDROID_KEYSTORE_PATH: /android/app/upload-keystore.jks + ANDROID_PLAY_STORE_JSON_KEY_PATH: /android/play-store-key.json + IOS_DIST_CERT_PATH: /ios/certs/dist_cert.p12 + IOS_CONNECT_API_KEY_PATH: /ios/certs/connect_api_key.p8 + IOS_PROV_PROFILE_PROJ_PATH: /ios/certs/profile.mobileprovision + IOS_PROV_PROFILE_DIRECTORY: "~/Library/MobileDevice/Provisioning\ Profiles/" + +permissions: + contents: write + pull-requests: write + +on: + push: + branches: + - dev + - main + paths: + - "app/**" + - ".github/workflows/mobile-deploy.yml" + pull_request: + paths: + - "app/**" + - ".github/workflows/mobile-deploy.yml" + +jobs: + build-ios: + runs-on: macos-latest + steps: + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + # some cocoapods won't compile with xcode 16.3 + xcode-version: "16.2" + + - uses: actions/checkout@v4 + - name: Install Mobile Dependencies + uses: ./.github/actions/mobile-setup + with: + app_path: ${{ env.APP_PATH }} + node_version: ${{ env.NODE_VERSION }} + ruby_version: ${{ env.RUBY_VERSION }} + workspace: ${{ env.WORKSPACE }} + + - name: Verify iOS Secrets + run: | + # Verify App Store Connect API Key exists and contains PEM header + if [ -z "${{ secrets.IOS_CONNECT_API_KEY_BASE64 }}" ]; then + echo "❌ Error: App Store Connect API Key cannot be empty" + exit 1 + fi + # Verify Issuer ID is in correct format (UUID) + if ! echo "${{ secrets.IOS_CONNECT_ISSUER_ID }}" | grep -E "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" >/dev/null; then + echo "❌ Error: Invalid App Store Connect Issuer ID format (should be UUID)" + exit 1 + fi + # Verify Key ID is in correct format (alphanumeric) + if ! echo "${{ secrets.IOS_CONNECT_KEY_ID }}" | grep -E "^[A-Z0-9]{10}$" >/dev/null; then + echo "❌ Error: Invalid App Store Connect Key ID format" + exit 1 + fi + # Verify P12 password is not empty and meets basic security requirements + if [ -z "${{ secrets.IOS_P12_PASSWORD }}" ]; then + echo "❌ Error: P12 password cannot be empty" + exit 1 + fi + # Verify base64 secrets are not empty + if [ -z "${{ secrets.IOS_DIST_CERT_BASE64 }}" ]; then + echo "❌ Error: Distribution certificate cannot be empty" + exit 1 + fi + if [ -z "${{ secrets.IOS_PROV_PROFILE_BASE64 }}" ]; then + echo "❌ Error: Provisioning profile cannot be empty" + exit 1 + fi + echo "✅ All iOS secrets verified successfully!" + + - name: Decode certificate and profile + run: | + mkdir -p "${{ env.APP_PATH }}$(dirname "${{ env.IOS_DIST_CERT_PATH }}")" + echo "${{ secrets.IOS_DIST_CERT_BASE64 }}" | base64 --decode > ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }} + echo "${{ secrets.IOS_PROV_PROFILE_BASE64 }}" | base64 --decode > ${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }} + echo "${{ secrets.IOS_CONNECT_API_KEY_BASE64 }}" | base64 --decode > ${{ env.APP_PATH }}${{ env.IOS_CONNECT_API_KEY_PATH }} + + # for debugging...which can take some time :( + - name: Verify ios secret checksums + if: false # for debugging + run: | + echo "SHA256 of dist_cert.p12:" + shasum -a 256 ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }} + echo "SHA256 of profile.mobileprovision:" + shasum -a 256 ${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }} + echo "SHA256 of connect_api_key.p8:" + shasum -a 256 ${{ env.APP_PATH }}${{ env.IOS_CONNECT_API_KEY_PATH }} + echo "Certificate file size:" + ls -l ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }} + echo "SHA256 of password:" + echo -n "${{ secrets.IOS_P12_PASSWORD }}" | shasum -a 256 + echo "SHA256 of connect_api_key_base64:" + echo -n "${{ secrets.IOS_CONNECT_API_KEY_BASE64 }}" | shasum -a 256 + echo "Verifying certificate..." + if openssl pkcs12 -in ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }} -password pass:'${{ secrets.IOS_P12_PASSWORD }}' -info >/dev/null 2>&1 || openssl pkcs12 -in ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }} -password pass:'${{ secrets.IOS_P12_PASSWORD }}' -info 2>&1 | grep -q "MAC:"; then + echo "✅ Certificate verification successful (algorithm warning can be safely ignored)" + else + echo "❌ Certificate verification failed - please check certificate validity" + exit 1 + fi + + - name: Verify iOS certificate and environment + if: ${{ !env.ACT }} + run: | + # Check if certificate directory exists + if [ ! -d "${{ env.APP_PATH }}/ios/certs" ]; then + echo "❌ Error: iOS certificates directory not found at ${{ env.APP_PATH }}/ios/certs" + exit 1 + fi + + # Check if certificate file exists + if [ ! -f "${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }}" ]; then + echo "❌ Error: Distribution certificate not found at ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }}" + exit 1 + fi + + # Check certificate file permissions + CERT_PERMS=$(ls -l "${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }}" | awk '{print $1}') + if [ "$CERT_PERMS" != "-rw-r--r--" ]; then + echo "❌ Error: Distribution certificate has incorrect permissions: $CERT_PERMS" + echo "Expected: -rw-r--r--" + exit 1 + fi + + # Check certificate file size + CERT_SIZE=$(stat -f%z "${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }}" 2>/dev/null || stat -c%s "${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }}") + if [ "$CERT_SIZE" -lt 1000 ]; then + echo "❌ Error: Distribution certificate file size ($CERT_SIZE bytes) is suspiciously small" + exit 1 + fi + + # Check if we can create a test keychain + TEST_KEYCHAIN="test.keychain" + if ! security create-keychain -p "" "$TEST_KEYCHAIN" >/dev/null 2>&1; then + echo "❌ Error: Unable to create test keychain. Check permissions." + exit 1 + fi + security delete-keychain "$TEST_KEYCHAIN" >/dev/null 2>&1 + + echo "✅ Certificate and environment verification passed!" + + - name: Install certificate + if: ${{ !env.ACT }} + run: | + security create-keychain -p "" build.keychain >/dev/null 2>&1 + security default-keychain -s build.keychain >/dev/null 2>&1 + security unlock-keychain -p "" build.keychain >/dev/null 2>&1 + security import ${{ env.APP_PATH }}${{ env.IOS_DIST_CERT_PATH }} -k build.keychain -P '${{ secrets.IOS_P12_PASSWORD }}' -T /usr/bin/codesign >/dev/null 2>&1 + security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain >/dev/null 2>&1 + + - name: Install provisioning profile + if: ${{ !env.ACT }} + env: + IOS_APP_IDENTIFIER: ${{ secrets.IOS_APP_IDENTIFIER }} + IOS_PROV_PROFILE_NAME: ${{ secrets.IOS_PROV_PROFILE_NAME }} + IOS_PROV_PROFILE_PATH: ${{ env.IOS_PROV_PROFILE_PATH }} + IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }} + run: | + # Verify file exists before proceeding + echo "Checking for provisioning profile at: ${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" + ls -l "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" + if [ ! -f "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" ]; then + echo "❌ Error: Provisioning profile not found at specified path." + exit 1 + fi + echo "Provisioning profile found." + + # Print file details + echo "Provisioning Profile File Details:" + echo "--------------------------------" + echo "File size: $(stat -f%z "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" 2>/dev/null || stat -c%s "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}") bytes" + echo "File permissions: $(ls -l "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" | awk '{print $1}')" + echo "File owner: $(ls -l "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" | awk '{print $3}')" + echo "--------------------------------" + + # Create a temporary plist file to extract UUID + TEMP_PLIST_PATH=$(mktemp /tmp/profile_plist.XXXXXX) + + # Extract plist from mobileprovision file + echo "Extracting plist from provisioning profile..." + security cms -D -i "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" -o "$TEMP_PLIST_PATH" + if [ $? -ne 0 ]; then + echo "❌ Error: Failed to extract plist from provisioning profile" + exit 1 + fi + + # Extract UUID and profile name from plist + echo "Extracting UUID and profile name from plist..." + PROFILE_UUID=$(/usr/libexec/PlistBuddy -c "Print :UUID" "$TEMP_PLIST_PATH" 2>/dev/null) + if [ $? -ne 0 ] || [ -z "$PROFILE_UUID" ]; then + echo "❌ Error: Failed to extract UUID from provisioning profile" + cat "$TEMP_PLIST_PATH" | head -20 + exit 1 + fi + + # Extract the actual profile name from within the file + PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$TEMP_PLIST_PATH" 2>/dev/null) + if [ $? -ne 0 ] || [ -z "$PROFILE_NAME" ]; then + echo "⚠️ Warning: Failed to extract Name from provisioning profile, will use provided IOS_PROV_PROFILE_NAME" + PROFILE_NAME="$IOS_PROV_PROFILE_NAME" + fi + + echo "Profile UUID: $PROFILE_UUID" + echo "Profile Name: $PROFILE_NAME" + + # Install provisioning profile in the correct location with UUID filename + echo "Installing provisioning profile in filesystem..." + mkdir -p "/Users/runner/Library/MobileDevice/Provisioning Profiles" + + # Copy with the UUID as filename + UUID_TARGET_PATH="/Users/runner/Library/MobileDevice/Provisioning Profiles/${PROFILE_UUID}.mobileprovision" + cp -v "${{ env.APP_PATH }}${{ env.IOS_PROV_PROFILE_PROJ_PATH }}" "$UUID_TARGET_PATH" + + # Set correct permissions on the profile + chmod 644 "$UUID_TARGET_PATH" + chown runner:staff "$UUID_TARGET_PATH" + + # Save the profile path and name to environment for later steps + echo "IOS_PROV_PROFILE_PATH=$UUID_TARGET_PATH" >> $GITHUB_ENV + echo "IOS_PROV_PROFILE_NAME=$PROFILE_NAME" >> $GITHUB_ENV + + # Print provisioning profile information + echo "Provisioning Profile Information:" + echo "--------------------------------" + echo "Profile Name (from file): $PROFILE_NAME" + echo "Profile Name (from env): $IOS_PROV_PROFILE_NAME" + echo "Profile Path: $UUID_TARGET_PATH" + echo "Profile UUID: $PROFILE_UUID" + echo "App Identifier: $IOS_APP_IDENTIFIER" + echo "Team ID: $IOS_TEAM_ID" + echo "--------------------------------" + + # List all provisioning profiles in the system with detailed info + echo "List of all provisioning profiles in system:" + ls -la "/Users/runner/Library/MobileDevice/Provisioning Profiles/" + + # Clean up temp file + rm -f "$TEMP_PLIST_PATH" + + echo "✅ Provisioning profile installation steps completed." + + # 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 }} + 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 }} + IOS_CONNECT_ISSUER_ID: ${{ secrets.IOS_CONNECT_ISSUER_ID }} + IOS_CONNECT_KEY_ID: ${{ secrets.IOS_CONNECT_KEY_ID }} + IOS_P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }} + IOS_PROJECT_NAME: ${{ secrets.IOS_PROJECT_NAME }} + IOS_PROJECT_SCHEME: ${{ secrets.IOS_PROJECT_SCHEME }} + IOS_PROV_PROFILE_DIR: ${{ env.IOS_PROV_PROFILE_DIRECTORY }} + IOS_PROV_PROFILE_NAME: ${{ secrets.IOS_PROV_PROFILE_NAME }} + IOS_PROV_PROFILE_PATH: ${{ env.IOS_PROV_PROFILE_PATH }} + IOS_SIGNING_CERTIFICATE: ${{ secrets.IOS_SIGNING_CERTIFICATE }} + IOS_TESTFLIGHT_GROUPS: ${{ secrets.IOS_TESTFLIGHT_GROUPS }} + IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }} + IOS_TEAM_NAME: ${{ secrets.IOS_TEAM_NAME }} + NODE_OPTIONS: "--max-old-space-size=8192" + SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }} + SLACK_ANNOUNCE_CHANNEL_NAME: ${{ secrets.SLACK_ANNOUNCE_CHANNEL_NAME }} + timeout-minutes: 90 + run: | + cd ${{ env.APP_PATH }} + echo "--- Pre-Fastlane Diagnostics ---" + echo "Running as user: $(whoami)" + echo "Default keychain:" + security list-keychains -d user + echo "Identities in build.keychain:" + security find-identity -v -p codesigning build.keychain || echo "Failed to find identities in build.keychain" + echo "--- Starting Fastlane ---" + # 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 + + - name: Remove project.pbxproj updates we don't want to commit + run: | + PBXPROJ_FILE="app/ios/Self.xcodeproj/project.pbxproj" + + # Create a temporary file to store version info + echo "Extracting version information..." + rm -f versions.txt + grep -E 'CURRENT_PROJECT_VERSION = [0-9]+;|MARKETING_VERSION = [0-9]+\.[0-9]+\.[0-9]+;' "${PBXPROJ_FILE}" > versions.txt + + # Check if we have version information + if [ -s versions.txt ]; then + echo "Found version information. Resetting file and re-applying versions..." + + # Store the version values + CURRENT_VERSION=$(grep 'CURRENT_PROJECT_VERSION' versions.txt | head -1 | sed 's/.*CURRENT_PROJECT_VERSION = \([0-9]*\);.*/\1/') + MARKETING_VERSION=$(grep 'MARKETING_VERSION' versions.txt | head -1 | sed 's/.*MARKETING_VERSION = \([0-9]*\.[0-9]*\.[0-9]*\);.*/\1/') + + echo "Current version: $CURRENT_VERSION" + echo "Marketing version: $MARKETING_VERSION" + + # Reset the file to HEAD + git checkout HEAD -- "${PBXPROJ_FILE}" + + # Update the versions if they exist + if [ ! -z "$CURRENT_VERSION" ]; then + sed -i '' "s/\(CURRENT_PROJECT_VERSION = \)[0-9]*;/\1$CURRENT_VERSION;/g" "${PBXPROJ_FILE}" + fi + + if [ ! -z "$MARKETING_VERSION" ]; then + sed -i '' "s/\(MARKETING_VERSION = \)[0-9]*\.[0-9]*\.[0-9]*;/\1$MARKETING_VERSION;/g" "${PBXPROJ_FILE}" + fi + + echo "Version information successfully applied." + else + echo "No version information found. Resetting file..." + git checkout HEAD -- "${PBXPROJ_FILE}" + fi + + # Clean up + rm -f versions.txt + + - name: Get version from package.json + uses: ./.github/actions/get-version + with: + app_path: ${{ env.APP_PATH }} + + - name: Commit updated build number + if: ${{ !env.ACT }} + uses: ./.github/actions/push-changes + with: + commit_message: "incrementing ios build number for version ${{ env.VERSION }}" + commit_paths: "./app/ios/OpenPassport/Info.plist ./app/ios/Self.xcodeproj/project.pbxproj" + + build-android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Mobile Dependencies + uses: ./.github/actions/mobile-setup + with: + app_path: ${{ env.APP_PATH }} + node_version: ${{ env.NODE_VERSION }} + ruby_version: ${{ env.RUBY_VERSION }} + workspace: ${{ env.WORKSPACE }} + + # android specific steps + - name: Setup Java environment + uses: actions/setup-java@v3 + with: + distribution: "temurin" + java-version: ${{ env.JAVA_VERSION }} + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + accept-android-sdk-licenses: true + + - name: Install NDK + run: | + max_attempts=5 + attempt=1 + while [ $attempt -le $max_attempts ]; do + echo "Attempt $attempt of $max_attempts to install NDK..." + if sdkmanager "ndk;${{ env.ANDROID_NDK_VERSION }}"; then + echo "Successfully installed NDK" + exit 0 + fi + echo "Failed to install NDK on attempt $attempt" + if [ $attempt -eq $max_attempts ]; then + echo "All attempts to install NDK failed" + exit 1 + fi + # Exponential backoff: 2^attempt seconds + wait_time=$((2 ** attempt)) + echo "Waiting $wait_time seconds before retrying..." + sleep $wait_time + attempt=$((attempt + 1)) + done + + - name: Set Gradle JVM options + if: ${{ env.ACT }} # run when testing locally with act to prevent gradle crashes + run: | + echo "org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8" >> ${{ env.APP_PATH }}/android/gradle.properties + + - name: Decode Android Secrets + 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 + run: | + # Verify Play Store JSON key base64 secret exists and is valid + if [ -z "${{ secrets.ANDROID_PLAY_STORE_JSON_KEY_BASE64 }}" ]; then + echo "❌ Error: Play Store JSON key base64 secret cannot be empty" + exit 1 + fi + # Verify the base64 can be decoded + if ! echo "${{ secrets.ANDROID_PLAY_STORE_JSON_KEY_BASE64 }}" | base64 --decode >/dev/null 2>&1; then + echo "❌ Error: Invalid Play Store JSON key base64 format" + exit 1 + fi + # Verify keystore file exists and is valid + if [ ! -f "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" ]; then + echo "❌ Error: Keystore file was not created successfully" + exit 1 + fi + # Try to verify the keystore with the provided password + if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >/dev/null 2>&1; then + echo "❌ Error: Invalid keystore password" + exit 1 + fi + # Verify the key alias exists + if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" -alias "${{ secrets.ANDROID_KEY_ALIAS }}" >/dev/null 2>&1; then + echo "❌ Error: Key alias '${{ secrets.ANDROID_KEY_ALIAS }}' not found in keystore" + exit 1 + fi + # Verify the key password + if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" -alias "${{ secrets.ANDROID_KEY_ALIAS }}" -keypass "${{ secrets.ANDROID_KEY_PASSWORD }}" >/dev/null 2>&1; then + echo "❌ Error: Invalid key password" + exit 1 + fi + echo "✅ All Android secrets verified successfully!" + + - name: Build and upload to Google Play Internal Testing + 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 }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + ANDROID_PACKAGE_NAME: ${{ secrets.ANDROID_PACKAGE_NAME }} + 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_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 + + - name: Get version from package.json + uses: ./.github/actions/get-version + with: + app_path: ${{ env.APP_PATH }} + + - name: Commit updated build version + if: ${{ !env.ACT }} + uses: ./.github/actions/push-changes + with: + commit_message: "incrementing android build version for version ${{ env.VERSION }}" + commit_paths: "./app/android/app/build.gradle" diff --git a/app/.gitignore b/app/.gitignore index 7b8700026..d065a83f6 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -22,6 +22,7 @@ DerivedData *.xcuserstate ios/.xcode.env.local **/.xcode.env.local +ios/certs # Android/IntelliJ @@ -37,6 +38,9 @@ local.properties # debug bundled builds android/app/src/main/assets/*android.bundle android/app/src/main/res/*/node_modules* +android/.kotlin/ +android/app/upload-keystore.jks +android/app/play-store-key.json # node.js # @@ -55,6 +59,7 @@ yarn-error.log **/fastlane/Preview.html **/fastlane/screenshots **/fastlane/test_output +**/fastlane/.env.secrets # Bundle artifact *.jsbundle diff --git a/app/.prettierignore b/app/.prettierignore index 11053cea8..5d6c3d38e 100644 --- a/app/.prettierignore +++ b/app/.prettierignore @@ -5,4 +5,5 @@ node_modules/ src/assets/animations/ witnesscalc/ vendor/ -android/ \ No newline at end of file +android/ +*.md diff --git a/app/.ruby-version b/app/.ruby-version new file mode 100644 index 000000000..6a3913b04 --- /dev/null +++ b/app/.ruby-version @@ -0,0 +1 @@ +3.2.7 \ No newline at end of file diff --git a/app/Gemfile b/app/Gemfile index 5039155be..e8bce84e1 100644 --- a/app/Gemfile +++ b/app/Gemfile @@ -1,8 +1,20 @@ -source 'https://rubygems.org' +source "https://rubygems.org" # You may use http://rbenv.org/ or https://rvm.io/ to install and use this version -ruby ">= 2.6.10" +ruby ">= 3.2" # Exclude problematic versions of cocoapods and activesupport that causes build failures. -gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1' -gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0' \ No newline at end of file +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" + +group :development do + gem "dotenv" + # Exclude nokogiri for GitHub Actions and Act + gem "nokogiri", "~> 1.18", platform: :ruby unless ENV["GITHUB_ACTIONS"] || ENV["ACT"] +end + +plugins_path = File.join(File.dirname(__FILE__), "fastlane", "Pluginfile") +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/app/Gemfile.lock b/app/Gemfile.lock index 44b8ca7c5..bfd946d08 100644 --- a/app/Gemfile.lock +++ b/app/Gemfile.lock @@ -1,27 +1,57 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.6) + CFPropertyList (3.0.7) + base64 + nkf rexml - activesupport (6.1.7.4) - concurrent-ruby (~> 1.0, >= 1.0.2) + activesupport (7.2.2.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.4) - public_suffix (>= 2.0.2, < 6.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) + artifactory (3.0.17) atomos (0.1.3) + aws-eventstream (1.3.2) + aws-partitions (1.1069.0) + aws-sdk-core (3.220.1) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.99.0) + aws-sdk-core (~> 3, >= 3.216.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.182.0) + aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.11.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + benchmark (0.4.0) + bigdecimal (3.1.9) claide (1.1.0) - cocoapods (1.12.1) + cocoapods (1.16.2) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.12.1) + cocoapods-core (= 1.16.2) cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 1.6.0, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) cocoapods-search (>= 1.0.0, < 2.0) cocoapods-trunk (>= 1.6.0, < 2.0) @@ -33,8 +63,8 @@ GEM molinillo (~> 0.8.0) nap (~> 1.0) ruby-macho (>= 2.3.0, < 3.0) - xcodeproj (>= 1.21.0, < 2.0) - cocoapods-core (1.12.1) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) @@ -45,7 +75,7 @@ GEM public_suffix (~> 4.0) typhoeus (~> 1.0) cocoapods-deintegrate (1.0.5) - cocoapods-downloader (1.6.3) + cocoapods-downloader (2.1) cocoapods-plugins (1.0.0) nap cocoapods-search (1.0.1) @@ -53,48 +83,234 @@ GEM nap (>= 0.8, < 2.0) netrc (~> 0.11) cocoapods-try (1.2.0) + colored (1.2) colored2 (3.1.2) - concurrent-ruby (1.2.2) + commander (4.6.0) + highline (~> 2.0.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.0) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + drb (2.2.1) + emoji_regex (3.2.3) escape (0.0.4) ethon (0.16.0) ffi (>= 1.15.0) - ffi (1.15.5) + excon (0.112.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.1.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.0) + fastlane (2.227.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.0) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-plugin-increment_version_code (0.4.3) + fastlane-plugin-versioning_android (0.1.1) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + ffi (1.17.1) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - httpclient (2.8.3) - i18n (1.14.1) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.5.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.6.3) - minitest (5.18.1) + jmespath (1.6.2) + json (2.10.2) + jwt (2.10.1) + base64 + logger (1.6.6) + mini_magick (4.13.2) + mini_mime (1.1.5) + mini_portile2 (2.8.8) + minitest (5.25.5) molinillo (0.8.0) - nanaimo (0.3.0) + multi_json (1.15.0) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) nap (1.1.0) + naturally (2.2.1) netrc (0.11.0) + nkf (0.2.0) + nokogiri (1.18.5) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + optparse (0.6.0) + os (1.1.4) + plist (3.7.2) public_suffix (4.0.7) - rexml (3.2.5) + racc (1.8.1) + rake (13.2.1) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.4.1) + rouge (3.28.0) ruby-macho (2.5.1) - typhoeus (1.4.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + securerandom (0.4.1) + security (0.1.5) + signet (0.19.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + typhoeus (1.4.1) ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - xcodeproj (1.22.0) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (~> 3.2.4) - zeitwerk (2.6.8) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.0) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) PLATFORMS ruby DEPENDENCIES - cocoapods (~> 1.12) + activesupport (>= 6.1.7.5, != 7.1.0) + cocoapods (>= 1.13, != 1.15.1, != 1.15.0) + dotenv + fastlane (~> 2.227.0) + fastlane-plugin-increment_version_code + fastlane-plugin-versioning_android + nokogiri (~> 1.18) RUBY VERSION - ruby 2.6.10p210 + ruby 3.2.7p253 BUNDLED WITH - 1.17.2 + 2.4.19 diff --git a/app/android/build.gradle b/app/android/build.gradle index 247f7ae9f..0524c12b7 100644 --- a/app/android/build.gradle +++ b/app/android/build.gradle @@ -2,10 +2,10 @@ buildscript { ext { - buildToolsVersion = "34.0.0" + buildToolsVersion = "35.0.0" minSdkVersion = 23 - compileSdkVersion = 34 - targetSdkVersion = 34 + compileSdkVersion = 35 + targetSdkVersion = 35 ndkVersion = "26.1.10909125" kotlinVersion = "1.9.24" } diff --git a/app/fastlane/.env.secrets.example b/app/fastlane/.env.secrets.example new file mode 100644 index 000000000..ef4932820 --- /dev/null +++ b/app/fastlane/.env.secrets.example @@ -0,0 +1,22 @@ +ANDROID_KEYSTORE= +ANDROID_KEYSTORE_PASSWORD= +ANDROID_KEY_ALIAS= +ANDROID_KEY_PASSWORD= +ANDROID_PACKAGE_NAME= +ANDROID_PLAY_STORE_JSON_KEY_BASE64= +IOS_APP_IDENTIFIER= +IOS_CONNECT_API_KEY_BASE64= +IOS_CONNECT_ISSUER_ID= +IOS_CONNECT_KEY_ID= +IOS_DIST_CERT_BASE64= +IOS_PROJECT_NAME= +IOS_PROJECT_SCHEME= +IOS_PROV_PROFILE_BASE64= +IOS_PROV_PROFILE_NAME= +IOS_P12_PASSWORD= +IOS_SIGNING_CERTIFICATE= +IOS_TEAM_ID= +IOS_TEAM_NAME= +IOS_TESTFLIGHT_GROUPS= +SLACK_CHANNEL_ID= +SLACK_BOT_TOKEN= diff --git a/app/fastlane/DEV.md b/app/fastlane/DEV.md new file mode 100644 index 000000000..16a9fc732 --- /dev/null +++ b/app/fastlane/DEV.md @@ -0,0 +1,377 @@ +# Fastlane & CI/CD Development Guide 🚀 + +This document outlines how to work with the Fastlane setup and the GitHub Actions CI/CD pipeline for this mobile application. + +## 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-) + +## Prerequisites 🛠️ + +Before working with this setup, ensure you have the following installed: + +* **Ruby** - Fastlane requires Ruby (version 2.6.0 or higher recommended) +* **Bundler** - For managing Ruby dependencies +* **Xcode** - For iOS development (latest stable version recommended) +* **Android Studio** - For Android development +* **Node.js & Yarn** - For JavaScript dependencies +* **Docker** - Optional, required for local testing with `act` + +## Setup ⚙️ + +### Local Fastlane Setup + +1. Install Fastlane via Bundler: + ```bash + cd app + bundle install + ``` + +2. Verify installation: + ```bash + bundle exec fastlane --version + ``` + +### Secrets Management (`.env.secrets`) 🔑 + +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 + ``` + +2. **Populate Values:** Fill in the values in your newly created `.env.secrets` file. Obtain these credentials from the appropriate platform developer portals or your team's administrator. + +3. **Keep it Private:** The `.env.secrets` file is included in the project's `.gitignore` and **must not** be committed to the repository. + +4. **CI/CD Setup:** For the GitHub Actions workflow, these same secrets must be configured as [GitHub Actions Secrets](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions) in the repository settings. + +### Environment Secrets Reference 📝 + +#### Android Secrets 🤖 + +| Secret | Description | +|--------|-------------| +| `ANDROID_KEYSTORE` | Path to keystore file used for signing Android apps | +| `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 | + +#### iOS Secrets 🍏 + +| Secret | Description | +|--------|-------------| +| `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_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_PROV_PROFILE_BASE64` | Base64 encoded provisioning profile for the app | +| `IOS_PROV_PROFILE_NAME` | Name of the provisioning profile | +| `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 | + +## Workflow Overview 🔄 + +### Fastlane Lanes + +The project uses several custom Fastlane lanes to handle different build and deployment scenarios: + +#### iOS Lanes + +| Lane | Description | Usage | +|------|-------------|-------| +| `internal_test` | Builds a beta version and uploads to TestFlight | `bundle exec fastlane ios internal_test` | +| `deploy` | Builds a production version and uploads to App Store Connect | `bundle exec fastlane ios deploy` | +| `sync_version` | Syncs version from package.json to Info.plist | `bundle exec fastlane ios sync_version` | + +#### Android Lanes + +| Lane | Description | Usage | +|------|-------------|-------| +| `internal_test` | Builds a beta version and uploads to Google Play Internal Testing | `bundle exec fastlane android internal_test` | +| `deploy` | Builds a production version and uploads to Google Play Production | `bundle exec fastlane android deploy` | +| `sync_version` | Syncs version from package.json to build.gradle | `bundle exec fastlane android sync_version` | + +### 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 + +## Local Development 💻 + +### Package Scripts + +Several scripts in `app/package.json` facilitate common Fastlane and versioning tasks: + +#### Debug Builds 🐞 + +**`yarn ios:fastlane-debug`** / **`yarn android:fastlane-debug`** + +* Executes the `internal_test` Fastlane lane for the respective platforms +* 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 + +#### Forced Local Deployment 🚀 + +**`yarn force-local-upload-deploy`** +**`yarn force-local-upload-deploy:ios`** +**`yarn force-local-upload-deploy:android`** + +* Runs the `deploy` 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 + +#### Forced Local Testing 🧪 + +**`yarn force-local-upload-test`** +**`yarn force-local-upload-test:ios`** +**`yarn force-local-upload-test:android`** + +* Similar to deploy version, but runs `internal_test` lane locally +* Useful for testing the internal distribution process +* Uses `FORCE_UPLOAD_LOCAL_DEV=true` flag + +### Version Management 🏷️ + +**`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 + +**`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 + +### Local Testing with `act` 🧰 + +You can test the GitHub Actions workflow locally using [`act`](https://github.com/nektos/act): + +1. **Install `act`:** Follow the installation instructions in the `act` repository. + +2. **Run Jobs:** From the *root* of the project repository: + + ```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 + ``` + +3. **Advanced Usage:** + * 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 + * Network access to Apple/Google services may be limited + +## CI/CD Pipeline 🔄 + +The primary CI/CD workflow is defined in `.github/workflows/mobile-deploy.yml`. It automates the build and deployment process. + +### Triggers + +* **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 + +### Jobs + +The workflow consists of parallel jobs for each platform: + +#### `build-ios` Job + +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 + +#### `build-android` Job + +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 + +### Deployment Destinations + +* **Internal Testing:** + * iOS: TestFlight + * Android: Google Play Internal Testing track + * Triggered on pushes to `dev` branch and pull requests + +* **Production:** + * iOS: App Store Connect (ready for submission) + * Android: Google Play Production track + * Triggered on pushes to `main` branch + +## Auto Build Number Incrementing 🔢 + +The CI/CD pipeline automatically manages build numbers/version codes: + +### 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 + +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` + +### 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) + +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 + +## Platform-Specific Notes 📱 + +### Android Deployment Caveats ⚠️ + +There are important limitations when working with Android deployments: + +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 + +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 + +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 + +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 + +## Troubleshooting 🔍 + +### Version Syncing Issues + +If you encounter issues with version syncing between `package.json` and native projects: + +1. **Manual Sync:** + ```bash + yarn sync-versions + ``` + This runs the Fastlane lanes to sync versions without building or deploying. + +2. **Version Mismatch Checking:** + ```bash + # 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 + +### 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 + +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 + * Ensure binary is properly signed with distribution certificate + +### 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 + +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 + +## Additional Resources 📚 + +### Official Documentation + +* [Fastlane Documentation](https://docs.fastlane.tools/) +* [GitHub Actions Documentation](https://docs.github.com/en/actions) +* [App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi) +* [Google Play Developer API](https://developers.google.com/android-publisher) + +### Helpful Tools + +* [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 diff --git a/app/fastlane/Fastfile b/app/fastlane/Fastfile new file mode 100644 index 000000000..85c0ddf57 --- /dev/null +++ b/app/fastlane/Fastfile @@ -0,0 +1,293 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# https://docs.fastlane.tools/actions +# + +opt_out_usage + +require "bundler/setup" +require "base64" +require_relative "helpers" + +# load secrets before project configuration +Fastlane::Helpers.dev_load_dotenv_secrets +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"] +SIGNING_CERTIFICATE = ENV["IOS_SIGNING_CERTIFICATE"] + +# Environment setup +package_version = JSON.parse(File.read("../package.json"))["version"] +# most of these values are for local development +android_aab_path = "../android/app/build/outputs/bundle/release/app-release.aab" +android_gradle_file_path = "../android/app/build.gradle" +android_keystore_path = "../android/app/upload-keystore.jks" +android_play_store_json_key_path = "../android/app/play-store-key.json" +ios_connect_api_key_path = "../ios/certs/connect_api_key.p8" +ios_provisioning_profile_directory = "~/Library/MobileDevice/Provisioning\ Profiles" +ios_xcode_profile_path = "../ios/#{PROJECT_NAME}.xcodeproj" + +default_platform(:ios) + +platform :ios do + desc "Sync ios version" + lane :sync_version do + increment_version_number( + xcodeproj: "ios/#{PROJECT_NAME}.xcodeproj", + version_number: package_version, + ) + end + + desc "Push a new build to TestFlight Internal Testing" + lane :internal_test do + result = prepare_ios_build(prod_release: false) + + upload_to_testflight( + api_key: result[:api_key], + distribute_external: true, + groups: ENV["IOS_TESTFLIGHT_GROUPS"].split(","), + changelog: "", + skip_waiting_for_build_processing: false, + ) 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 v#{package_version} (Build #{result[:build_number]}) deployed to TestFlight", + title: "#{PROJECT_NAME}-#{package_version}-#{result[:build_number]}.ipa", + ) + else + UI.important("Skipping Slack notification: SLACK_CHANNEL_ID not set.") + 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" + + # Set up API key, profile, and potentially certificate for local dev + Fastlane::Helpers.ios_dev_setup_connect_api_key(ios_connect_api_key_path) + Fastlane::Helpers.ios_dev_setup_provisioning_profile(ios_provisioning_profile_directory) + Fastlane::Helpers.ios_dev_setup_certificate + else + # we need this for building ios apps in CI + # else build will hang on "[CP] Embed Pods Frameworks" + setup_ci( + keychain_name: "build.keychain", + ) + end + + required_env_vars = [ + "IOS_APP_IDENTIFIER", + "IOS_CONNECT_API_KEY_BASE64", + "IOS_CONNECT_API_KEY_PATH", + "IOS_CONNECT_ISSUER_ID", + "IOS_CONNECT_KEY_ID", + "IOS_PROJECT_NAME", + "IOS_PROJECT_SCHEME", + "IOS_PROV_PROFILE_NAME", + "IOS_PROV_PROFILE_PATH", + "IOS_TEAM_ID", + "IOS_TEAM_NAME", + ] + + target_platform = options[:prod_release] ? "App Store" : "TestFlight" + should_upload = Fastlane::Helpers.should_upload_app(target_platform) + workspace_path = File.expand_path("../ios/#{PROJECT_NAME}.xcworkspace", Dir.pwd) + 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) + Fastlane::Helpers.ios_verify_app_store_build_number(ios_xcode_profile_path) + Fastlane::Helpers.ios_verify_provisioning_profile + + api_key = app_store_connect_api_key( + 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, + ) + + # Update project to use manual code signing + update_code_signing_settings( + use_automatic_signing: false, + path: "ios/#{PROJECT_NAME}.xcodeproj", + team_id: ENV["IOS_TEAM_ID"], + targets: [PROJECT_NAME], + code_sign_identity: ios_signing_certificate_name, + profile_name: ENV["IOS_PROV_PROFILE_NAME"], + bundle_identifier: ENV["IOS_APP_IDENTIFIER"], + build_configurations: ["Release"], + ) + + clear_derived_data + + # Print final build settings before archiving + sh "xcodebuild -showBuildSettings -workspace #{workspace_path} " \ + "-scheme #{PROJECT_SCHEME} -configuration Release " \ + "| grep 'CODE_SIGN_STYLE\|PROVISIONING_PROFILE_SPECIFIER\|CODE_SIGN_IDENTITY\|DEVELOPMENT_TEAM' || true" + + cocoapods( + podfile: "ios/Podfile", + clean_install: true, + deployment: true, + ) + + ipa_path = build_app({ + workspace: "#{workspace_path}", + scheme: PROJECT_SCHEME, + export_method: "app-store", + output_directory: "build", + clean: true, + export_options: { + method: "app-store", + signingStyle: "manual", + provisioningProfiles: { + ENV["IOS_APP_IDENTIFIER"] => ENV["IOS_PROV_PROFILE_NAME"], + }, + signingCertificate: ios_signing_certificate_name, + teamID: ENV["IOS_TEAM_ID"], + }, + }) + + { + api_key: api_key, + build_number: build_number, + ipa_path: ipa_path, + should_upload: should_upload, + } + end +end + +platform :android do + desc "Sync android version" + lane :sync_version do + android_set_version_name( + version_name: package_version, + gradle_file: android_gradle_file_path, + ) + end + + desc "Push a new build to Google Play Internal Testing" + lane :internal_test do + upload_android_build(track: "internal") + end + + desc "Push a new build to Google Play Store" + lane :deploy do + upload_android_build(track: "production") + end + + private_lane :upload_android_build do |options| + if local_development + if ENV["ANDROID_KEYSTORE_PATH"].nil? + ENV["ANDROID_KEYSTORE_PATH"] = Fastlane::Helpers.android_create_keystore(android_keystore_path) + end + + if ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"].nil? + ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"] = Fastlane::Helpers.android_create_play_store_key(android_play_store_json_key_path) + end + end + + required_env_vars = [ + "ANDROID_KEYSTORE", + "ANDROID_KEYSTORE_PASSWORD", + "ANDROID_KEYSTORE_PATH", + "ANDROID_KEY_ALIAS", + "ANDROID_KEY_PASSWORD", + "ANDROID_PACKAGE_NAME", + "ANDROID_PLAY_STORE_JSON_KEY_PATH", + ] + + Fastlane::Helpers.verify_env_vars(required_env_vars) + version_code = Fastlane::Helpers.android_increment_version_code(android_gradle_file_path) + # TODO: uncomment when we have the permissions to run this action + # Fastlane::Helpers.android_verify_version_code(android_gradle_file_path) + + target_platform = options[:track] == "production" ? "Google Play" : "Internal Testing" + should_upload = Fastlane::Helpers.should_upload_app(target_platform) + + validate_play_store_json_key( + json_key: ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"], + ) + + Fastlane::Helpers.with_retry(max_retries: 3, delay: 10) do + gradle( + task: "clean bundleRelease", + project_dir: "android/", + properties: { + "android.injected.signing.store.file" => ENV["ANDROID_KEYSTORE_PATH"], + "android.injected.signing.store.password" => ENV["ANDROID_KEYSTORE_PASSWORD"], + "android.injected.signing.key.alias" => ENV["ANDROID_KEY_ALIAS"], + "android.injected.signing.key.password" => ENV["ANDROID_KEY_PASSWORD"], + }, + ) + end + + upload_to_play_store( + track: options[:track], + json_key: ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"], + package_name: ENV["ANDROID_PACKAGE_NAME"], + skip_upload_changelogs: true, + skip_upload_images: true, + skip_upload_screenshots: true, + track_promote_release_status: "completed", + aab: android_aab_path, + ) if should_upload && android_has_permissions + + # Notify Slack about the new build + if ENV["SLACK_CHANNEL_ID"] + Fastlane::Helpers.upload_file_to_slack( + file_path: android_aab_path, + channel_id: ENV["SLACK_CHANNEL_ID"], + initial_comment: "🤖 Android v#{package_version} (Build #{version_code}) deployed to #{target_platform}", + title: "#{PROJECT_NAME}-#{package_version}-#{version_code}.aab", + ) + else + UI.important("Skipping Slack notification: SLACK_CHANNEL_ID not set.") + end + end +end diff --git a/app/fastlane/Pluginfile b/app/fastlane/Pluginfile new file mode 100644 index 000000000..ad2875569 --- /dev/null +++ b/app/fastlane/Pluginfile @@ -0,0 +1,6 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +gem 'fastlane-plugin-increment_version_code' +gem 'fastlane-plugin-versioning_android' diff --git a/app/fastlane/README.md b/app/fastlane/README.md new file mode 100644 index 000000000..61b95b6cc --- /dev/null +++ b/app/fastlane/README.md @@ -0,0 +1,77 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## iOS + +### ios sync_version + +```sh +[bundle exec] fastlane ios sync_version +``` + +Sync ios version + +### ios internal_test + +```sh +[bundle exec] fastlane ios internal_test +``` + +Push a new build to TestFlight Internal Testing + +### ios deploy + +```sh +[bundle exec] fastlane ios deploy +``` + +Prepare a new build for App Store submission + +---- + + +## Android + +### android sync_version + +```sh +[bundle exec] fastlane android sync_version +``` + +Sync android version + +### android internal_test + +```sh +[bundle exec] fastlane android internal_test +``` + +Push a new build to Google Play Internal Testing + +### android deploy + +```sh +[bundle exec] fastlane android deploy +``` + +Push a new build to Google Play Store + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/app/fastlane/helpers.rb b/app/fastlane/helpers.rb new file mode 100644 index 000000000..13a8088b0 --- /dev/null +++ b/app/fastlane/helpers.rb @@ -0,0 +1,739 @@ +require "bundler/setup" +require "fastlane" +require "tempfile" +require "fileutils" +require "base64" +require "shellwords" +require "net/http" +require "uri" +require "json" + +# Load secrets before defining constants +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 + end +end + +# Call load_dotenv_secrets before setting constants +Fastlane::Helpers.dev_load_dotenv_secrets + +# Now set constants after secrets are loaded +SLACK_TOKEN = ENV["SLACK_API_TOKEN"] +CHANNEL_NAME = ENV["SLACK_ANNOUNCE_CHANNEL_NAME"] || "deploy-mobile" + +module Fastlane + module Helpers + @@android_has_permissions = false + + ### UI and Reporting Methods ### + def self.report_error(message, suggestion = nil, abort_message = nil) + UI.error("❌ #{message}") + UI.error(suggestion) if suggestion + UI.abort_with_message!(abort_message || message) + end + + def self.report_success(message) + UI.success("✅ #{message}") + end + + ### Environment and Configuration Methods ### + def self.verify_env_vars(required_vars) + missing_vars = required_vars.select { |var| ENV[var].nil? || ENV[var].to_s.strip.empty? } + + if missing_vars.any? + report_error( + "Missing required environment variables: #{missing_vars.join(", ")}", + "Please check your secrets", + "Environment verification failed" + ) + else + report_success("All required environment variables are present") + end + end + + def self.should_upload_app(platform) + if ENV["ACT"] == "true" + puts "Skipping upload to #{platform} we are testing using `act`" + return false + end + + if ENV["IS_PR"] == "true" + puts "Skipping upload to #{platform} because we are in a pull request" + return false + end + + # upload app if we are in CI or forcing local upload + ENV["CI"] == "true" || ENV["FORCE_UPLOAD_LOCAL_DEV"] == "true" + end + + def self.confirm_force_upload + UI.important "⚠️ FORCE_UPLOAD_LOCAL_DEV is set to true. This will upload the build to the store." + UI.important "Are you sure you want to continue? (y/n)" + response = STDIN.gets.chomp + unless response.downcase == "y" + UI.user_error!("Upload cancelled by user") + end + end + + def self.with_retry(max_retries: 3, delay: 5) + attempts = 0 + begin + yield + rescue => e + attempts += 1 + if attempts < max_retries + UI.important("Retry ##{attempts} after error: #{e.message}") + sleep(delay) + retry + else + UI.user_error!("Failed after #{max_retries} retries: #{e.message}") + end + end + end + + def self.ios_verify_app_store_build_number(ios_xcode_profile_path) + api_key = Fastlane::Actions::AppStoreConnectApiKeyAction.run( + key_id: ENV["IOS_CONNECT_KEY_ID"], + issuer_id: ENV["IOS_CONNECT_ISSUER_ID"], + key_filepath: ENV["IOS_CONNECT_API_KEY_PATH"], + in_house: false, + ) + + latest_build = Fastlane::Actions::LatestTestflightBuildNumberAction.run( + api_key: api_key, + app_identifier: ENV["IOS_APP_IDENTIFIER"], + platform: "ios", + ) + + project = Xcodeproj::Project.open(ios_xcode_profile_path) + target = project.targets.first + current_build = target.build_configurations.first.build_settings["CURRENT_PROJECT_VERSION"] + + if current_build.to_i <= latest_build.to_i + report_error( + "Build number must be greater than latest TestFlight build!", + "Latest TestFlight build: #{latest_build}\nCurrent build: #{current_build}\nPlease increment the build number in the project settings", + "Build number verification failed" + ) + else + report_success("Build number verified (Current: #{current_build}, Latest TestFlight: #{latest_build})") + end + end + + def self.ios_ensure_generic_versioning(ios_xcode_profile_path) + puts "Opening Xcode project at: #{File.expand_path(ios_xcode_profile_path)}" + + unless File.exist?(ios_xcode_profile_path) + report_error( + "Xcode project not found at #{project_path}", + "Please ensure you're running this command from the correct directory", + "Project file not found" + ) + end + + project = Xcodeproj::Project.open(ios_xcode_profile_path) + + project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings["VERSIONING_SYSTEM"] != "apple-generic" + puts "Enabling Apple Generic Versioning for #{target.name} - #{config.name}" + config.build_settings["VERSIONING_SYSTEM"] = "apple-generic" + config.build_settings["CURRENT_PROJECT_VERSION"] ||= "1" + end + end + end + + project.save + report_success("Enabled Apple Generic Versioning in Xcode project") + end + + def self.ios_increment_build_number(ios_xcode_profile_path) + # First ensure Apple Generic Versioning is enabled + ios_ensure_generic_versioning(ios_xcode_profile_path) + + api_key = Fastlane::Actions::AppStoreConnectApiKeyAction.run( + key_id: ENV["IOS_CONNECT_KEY_ID"], + issuer_id: ENV["IOS_CONNECT_ISSUER_ID"], + key_filepath: ENV["IOS_CONNECT_API_KEY_PATH"], + in_house: false, + ) + + latest_build = Fastlane::Actions::LatestTestflightBuildNumberAction.run( + api_key: api_key, + app_identifier: ENV["IOS_APP_IDENTIFIER"], + platform: "ios", + ) + + new_build_number = latest_build + 1 + + Fastlane::Actions::IncrementBuildNumberAction.run( + build_number: new_build_number, + xcodeproj: ios_xcode_profile_path, + ) + + report_success("Incremented build number to #{new_build_number} (previous TestFlight build: #{latest_build})") + + new_build_number + end + + def self.ios_dev_setup_certificate + unless ENV["IOS_DIST_CERT_BASE64"] + report_error( + "Missing IOS_DIST_CERT_BASE64 environment variable.", + "This variable is required for local certificate installation.", + "Certificate installation failed" + ) + end + unless ENV["IOS_P12_PASSWORD"] + report_error( + "Missing IOS_P12_PASSWORD environment variable.", + "This password is required to import the certificate (.p12 file).", + "Certificate installation failed" + ) + end + + decoded_cert_data = Base64.decode64(ENV["IOS_DIST_CERT_BASE64"]) + if decoded_cert_data.empty? + report_error( + "IOS_DIST_CERT_BASE64 seems to be empty or invalid.", + "Please check the value of the environment variable.", + "Certificate decoding failed" + ) + end + + cert_password = ENV["IOS_P12_PASSWORD"] || "" + temp_p12 = nil + + begin + temp_p12 = Tempfile.new(["fastlane_local_cert", ".p12"]) + temp_p12.binmode + temp_p12.write(decoded_cert_data) + temp_p12.close + puts "Temporarily wrote decoded certificate to: #{temp_p12.path}" + + # Import the certificate into the default keychain + # Omitting -k targets the default keychain. + # -T /usr/bin/codesign allows codesign to use the key without prompting every time. + import_command = "security import #{Shellwords.escape(temp_p12.path)} -P #{Shellwords.escape(cert_password)} -T /usr/bin/codesign" + puts "Running: #{import_command}" + import_output = `#{import_command} 2>&1` + + unless $?.success? + report_error( + "Failed to import certificate into default keychain.", + "Command: #{import_command}\nOutput: #{import_output}", + "Certificate import failed" + ) + end + report_success("Certificate imported successfully into default keychain.") + rescue => e + report_error("An error occurred during certificate installation: #{e.message}", e.backtrace.join("\n"), "Certificate installation failed") + ensure + # Clean up temporary file + if temp_p12 + temp_p12.unlink + puts "Cleaned up temp certificate: #{temp_p12.path}" + end + end + end + + def self.ios_dev_setup_connect_api_key(api_key_path) + api_key_full_path = File.expand_path(api_key_path, File.dirname(__FILE__)) + ENV["IOS_CONNECT_API_KEY_PATH"] = api_key_full_path + + if ENV["IOS_CONNECT_API_KEY_BASE64"] + puts "Decoding iOS Connect API key..." + begin + decoded_key = Base64.decode64(ENV["IOS_CONNECT_API_KEY_BASE64"]) + if decoded_key.empty? + report_error( + "IOS_CONNECT_API_KEY_BASE64 seems to be empty or invalid.", + "Please check the value of the environment variable.", + "Connect API Key decoding failed" + ) + end + FileUtils.mkdir_p(File.dirname(api_key_full_path)) + File.write(api_key_full_path, decoded_key) + report_success("Connect API Key written to: #{api_key_full_path}") + rescue => e + report_error("Error writing decoded API key: #{e.message}", nil, "Connect API Key setup failed") + end + elsif !File.exist?(api_key_full_path) + report_error( + "IOS_CONNECT_API_KEY_BASE64 not set and key file not found.", + "Please provide the key via environment variable or ensure it exists at #{api_key_full_path}", + "Connect API Key setup failed" + ) + else + puts "Using existing Connect API Key at: #{api_key_full_path}" + end + + begin + verified_path = File.realpath(api_key_full_path) + puts "Verified Connect API Key path: #{verified_path}" + verified_path + rescue Errno::ENOENT + report_error("Connect API Key file not found at expected location: #{api_key_full_path}", nil, "Connect API Key verification failed") + end + end + + def self.ios_dev_setup_provisioning_profile(provisioning_profile_directory) + unless ENV["IOS_PROV_PROFILE_BASE64"] + report_error( + "Missing IOS_PROV_PROFILE_BASE64 environment variable.", + "This variable is required for local development profile setup.", + "Provisioning profile setup failed" + ) + end + + decoded_profile_data = Base64.decode64(ENV["IOS_PROV_PROFILE_BASE64"]) + if decoded_profile_data.empty? + report_error( + "IOS_PROV_PROFILE_BASE64 seems to be empty or invalid.", + "Please check the value of the environment variable.", + "Provisioning profile decoding failed" + ) + end + + temp_profile = nil + temp_plist = nil + final_path = nil + + begin + temp_profile = Tempfile.new(["fastlane_local_profile", ".mobileprovision"]) + temp_profile.binmode + temp_profile.write(decoded_profile_data) + temp_profile.close + puts "Temporarily wrote decoded profile to: #{temp_profile.path}" + + temp_plist = Tempfile.new(["fastlane_temp_plist", ".plist"]) + temp_plist_path = temp_plist.path + temp_plist.close + puts "Temporary plist path: #{temp_plist_path}" + + security_command = "security cms -D -i #{Shellwords.escape(temp_profile.path)} -o #{Shellwords.escape(temp_plist_path)}" + puts "Running: #{security_command}" + security_output = `#{security_command} 2>&1` + + unless $?.success? + report_error( + "Failed to extract plist from provisioning profile using security cms.", + "Command failed: #{security_command}\nOutput: #{security_output}", + "Provisioning profile UUID extraction failed" + ) + end + puts "Successfully extracted plist." + + unless File.exist?(temp_plist_path) && File.size(temp_plist_path) > 0 + report_error( + "Plist file was not created or is empty after security command.", + "Expected plist at: #{temp_plist_path}", + "Provisioning profile UUID extraction failed" + ) + end + + plistbuddy_command = "/usr/libexec/PlistBuddy -c \"Print :UUID\" #{Shellwords.escape(temp_plist_path)}" + puts "Running: #{plistbuddy_command}" + profile_uuid = `#{plistbuddy_command} 2>&1`.strip + + unless $?.success? && !profile_uuid.empty? && profile_uuid !~ /does not exist/ + report_error( + "Failed to extract UUID using PlistBuddy or UUID was empty.", + "Command: #{plistbuddy_command}\nOutput: #{profile_uuid}", + "Provisioning profile UUID extraction failed" + ) + end + report_success("Extracted profile UUID: #{profile_uuid}") + + profile_dir = File.expand_path(provisioning_profile_directory) + FileUtils.mkdir_p(profile_dir) + final_path = File.join(profile_dir, "#{profile_uuid}.mobileprovision") + + puts "Copying profile to: #{final_path}" + FileUtils.cp(temp_profile.path, final_path) + report_success("Provisioning profile installed successfully.") + + ENV["IOS_PROV_PROFILE_PATH"] = final_path + rescue => e + report_error("An error occurred during provisioning profile setup: #{e.message}", e.backtrace.join("\n"), "Provisioning profile setup failed") + ensure + if temp_profile + temp_profile.unlink + puts "Cleaned up temp profile: #{temp_profile.path}" + end + if temp_plist_path && File.exist?(temp_plist_path) + File.unlink(temp_plist_path) + puts "Cleaned up temp plist: #{temp_plist_path}" + end + end + + final_path + end + + def self.ios_verify_provisioning_profile + profile_path = ENV["IOS_PROV_PROFILE_PATH"] + + unless profile_path && !profile_path.empty? + report_error( + "ENV['IOS_PROV_PROFILE_PATH'] is not set.", + "Ensure ios_dev_setup_provisioning_profile ran successfully or the path is set correctly in CI.", + "Provisioning profile verification failed" + ) + end + + puts "Verifying provisioning profile exists at: #{profile_path}" + + begin + File.realpath(profile_path) + report_success("iOS provisioning profile verified successfully at #{profile_path}") + rescue Errno::ENOENT + report_error("Provisioning profile not found at: #{profile_path}") + rescue => e + report_error("Error accessing provisioning profile at #{profile_path}: #{e.message}") + end + + # Print current user + current_user = ENV["USER"] || `whoami`.strip + puts "Current user: #{current_user}" + + # List all provisioning profiles in user's directory + profiles_dir = File.expand_path("~/Library/MobileDevice/Provisioning Profiles") + if Dir.exist?(profiles_dir) + puts "Listing mobile provisioning profiles in #{profiles_dir}:" + profiles = Dir.glob(File.join(profiles_dir, "*.mobileprovision")) + if profiles.empty? + puts " No provisioning profiles found" + else + profiles.each do |profile| + uuid = File.basename(profile, ".mobileprovision") + puts " - #{uuid}.mobileprovision" + end + puts "Total provisioning profiles found: #{profiles.count}" + end + else + puts "Provisioning profiles directory not found at: #{profiles_dir}" + end + + # Advanced checks for provisioning profile + puts "\n--- Advanced Provisioning Profile Diagnostics ---" + + # Check if profile can be parsed + if File.exist?(profile_path) + puts "Testing if profile can be parsed with security tool:" + temp_plist = Tempfile.new(["profile_info", ".plist"]) + begin + security_cmd = "security cms -D -i #{Shellwords.escape(profile_path)} -o #{Shellwords.escape(temp_plist.path)}" + security_output = `#{security_cmd} 2>&1` + security_success = $?.success? + + if security_success + puts "✅ Profile can be parsed successfully" + + # Extract and display important profile information + puts "\nExtracting profile information:" + + # Get profile UUID + uuid_cmd = "/usr/libexec/PlistBuddy -c 'Print :UUID' #{Shellwords.escape(temp_plist.path)}" + uuid = `#{uuid_cmd}`.strip + puts "Profile UUID: #{uuid}" + + # Get App ID/Bundle ID + app_id_cmd = "/usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' #{Shellwords.escape(temp_plist.path)}" + app_id = `#{app_id_cmd}`.strip + puts "App Identifier: #{app_id}" + + # Get Team ID + team_id_cmd = "/usr/libexec/PlistBuddy -c 'Print :TeamIdentifier:0' #{Shellwords.escape(temp_plist.path)}" + team_id = `#{team_id_cmd}`.strip + puts "Team Identifier: #{team_id}" + + # Get profile type (development, distribution, etc.) + profile_type_cmd = "/usr/libexec/PlistBuddy -c 'Print :Entitlements:get-task-allow' #{Shellwords.escape(temp_plist.path)} 2>/dev/null" + get_task_allow = `#{profile_type_cmd}`.strip.downcase + + if get_task_allow == "true" + puts "Profile Type: Development" + else + distribution_cmd = "/usr/libexec/PlistBuddy -c 'Print :ProvisionsAllDevices' #{Shellwords.escape(temp_plist.path)} 2>/dev/null" + provisions_all = `#{distribution_cmd}`.strip.downcase + + if provisions_all == "true" + puts "Profile Type: Enterprise Distribution" + else + puts "Profile Type: App Store Distribution" + end + end + + # Get expiration date + expiration_cmd = "/usr/libexec/PlistBuddy -c 'Print :ExpirationDate' #{Shellwords.escape(temp_plist.path)}" + expiration = `#{expiration_cmd}`.strip + puts "Expiration Date: #{expiration}" + else + puts "❌ Failed to parse profile: #{security_output}" + end + ensure + temp_plist.close + temp_plist.unlink + end + end + + # Check code signing identities + puts "\nInspecting code signing identities:" + signing_identities = `security find-identity -v -p codesigning 2>&1` + puts signing_identities + + # Check keychain configuration + puts "\nKeychain configuration:" + puts `security list-keychains -d user 2>&1` + + # Check Xcode configuration + puts "\nXcode code signing search paths:" + puts "Provisioning profiles search path: ~/Library/MobileDevice/Provisioning Profiles/" + puts "Recommended check: In Xcode settings, verify your Apple ID is correctly logged in" + + puts "--- End of Provisioning Profile Diagnostics ---\n" + end + + ### Android-specific Methods ### + + def self.android_create_keystore(keystore_path) + if ENV["ANDROID_KEYSTORE"] + puts "Decoding Android keystore..." + FileUtils.mkdir_p(File.dirname(keystore_path)) + File.write(keystore_path, Base64.decode64(ENV["ANDROID_KEYSTORE"])) + end + + File.realpath(keystore_path) + end + + def self.android_create_play_store_key(key_path) + if ENV["ANDROID_PLAY_STORE_JSON_KEY_BASE64"] + puts "Decoding Android Play Store JSON key..." + FileUtils.mkdir_p(File.dirname(key_path)) + File.write(key_path, Base64.decode64(ENV["ANDROID_PLAY_STORE_JSON_KEY_BASE64"])) + end + + File.realpath(key_path) + end + + # unused to do api key permissions + def self.android_verify_version_code(gradle_file_path) + latest_version = Fastlane::Actions::GooglePlayTrackVersionCodesAction.run( + track: "internal", + json_key: ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"], + package_name: ENV["ANDROID_PACKAGE_NAME"], + ).first + + version_code_line = File.readlines(gradle_file_path).find { |line| line.include?("versionCode") } + current_version = version_code_line.match(/versionCode\s+(\d+)/)[1].to_i + + if current_version <= latest_version + report_error( + "Version code must be greater than latest Play Store version!", + "Latest Play Store version: #{latest_version}\nCurrent version: #{current_version}\nPlease increment the version code in android/app/build.gradle", + "Version code verification failed" + ) + else + report_success("Version code verified (Current: #{current_version}, Latest Play Store: #{latest_version})") + end + end + + def self.android_increment_version_code(gradle_file_path) + gradle_file_full_path = File.expand_path(gradle_file_path, File.dirname(__FILE__)) + + unless File.exist?(gradle_file_full_path) + UI.error("Could not find build.gradle at: #{gradle_file_full_path}") + UI.user_error!("Please ensure the Android project is properly set up") + end + + # Read current version code + gradle_content = File.read(gradle_file_full_path) + version_code_match = gradle_content.match(/versionCode\s+(\d+)/) + current_version_code = version_code_match ? version_code_match[1].to_i : 0 + + # TODO: fetch version code from play store when we have permissions + new_version = current_version_code + 1 + + # Update version code in file + if @@android_has_permissions + updated_content = gradle_content.gsub(/versionCode\s+\d+/, "versionCode #{new_version}") + File.write(gradle_file_full_path, updated_content) + end + + report_success("Version code incremented from #{current_version_code} to #{new_version}") + + @@android_has_permissions ? new_version : current_version_code + end + + # Helper to log keychain diagnostics + def self.log_keychain_diagnostics(certificate_name) + puts "--- Fastlane Pre-Build Diagnostics ---" + begin + system("echo 'Running as user: $(whoami)'") + system("echo 'Default keychain:'") + system("security list-keychains -d user") + system("echo 'Identities in build.keychain:'") + # Use the absolute path expected in the GH runner environment + keychain_path = "/Users/runner/Library/Keychains/build.keychain-db" + system("security find-identity -v -p codesigning #{keychain_path} || echo 'No identities found or build.keychain doesn\'t exist at #{keychain_path}'") + rescue => e + puts "Error running security command: #{e.message}" + end + puts "Certificate name constructed by Fastlane: #{certificate_name}" + puts "--- End Fastlane Diagnostics ---" + end + + ### Slack Methods ### + # Uploads a file to Slack using the files.upload API endpoint. + # Handles multipart/form-data request construction. + # + # Args: + # file_path (String): Path to the file to upload. + # channel_id (String): ID of the channel to upload the file to. + # initial_comment (String, optional): Message to post alongside the file. + # thread_ts (String, optional): Timestamp of a message to reply to (creates a thread). + # title (String, optional): Title for the uploaded file (defaults to filename). + def self.upload_file_to_slack(file_path:, channel_id:, initial_comment: nil, thread_ts: nil, title: nil) + unless SLACK_TOKEN && !SLACK_TOKEN.strip.empty? + report_error("Missing SLACK_API_TOKEN environment variable.", "Cannot upload file to Slack without API token.", "Slack Upload Failed") + return false + end + + unless File.exist?(file_path) + report_error("File not found at path: #{file_path}", "Please ensure the file exists before uploading.", "Slack Upload Failed") + return false + end + + file_name = File.basename(file_path) + file_size = File.size(file_path) + file_title = title || file_name + + begin + upload_url = nil + file_id = nil + + # Step 1: Get Upload URL + with_retry(max_retries: 3, delay: 5) do + UI.message("Step 1: Getting Slack upload URL for #{file_name}...") + uri = URI.parse("https://slack.com/api/files.getUploadURLExternal") + request = Net::HTTP::Post.new(uri) + request["Authorization"] = "Bearer #{SLACK_TOKEN}" + request.set_form_data(filename: file_name, length: file_size) + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + response = http.request(request) + + unless response.is_a?(Net::HTTPSuccess) + raise "Slack API (files.getUploadURLExternal) failed: #{response.code} #{response.body}" + end + + response_json = JSON.parse(response.body) + unless response_json["ok"] + raise "Slack API Error (files.getUploadURLExternal): #{response_json["error"]}" + end + + upload_url = response_json["upload_url"] + file_id = response_json["file_id"] + UI.message("Got upload URL and file ID: #{file_id}") + end + + # Step 2: Upload file content to the obtained URL + with_retry(max_retries: 3, delay: 5) do + UI.message("Step 2: Uploading file content to Slack...") + upload_uri = URI.parse(upload_url) + # Net::HTTP::Post requires the request body to be an IO object or string + # Reading the file content here for the request body + file_content = File.binread(file_path) + + upload_request = Net::HTTP::Post.new(upload_uri) + upload_request.body = file_content + # Slack's upload URL expects the raw file bytes in the body + # Content-Type is often application/octet-stream, but Slack might infer + upload_request["Content-Type"] = "application/octet-stream" + upload_request["Content-Length"] = file_size.to_s + + upload_http = Net::HTTP.new(upload_uri.host, upload_uri.port) + upload_http.use_ssl = true + upload_response = upload_http.request(upload_request) + + # Check for a 200 OK response for the file upload itself + unless upload_response.is_a?(Net::HTTPOK) + raise "File content upload failed: #{upload_response.code} #{upload_response.message} Body: #{upload_response.body}" + end + UI.message("File content uploaded successfully.") + end + + # Step 3: Complete the upload + final_file_info = nil + with_retry(max_retries: 3, delay: 5) do + UI.message("Step 3: Completing Slack upload for file ID #{file_id}...") + complete_uri = URI.parse("https://slack.com/api/files.completeUploadExternal") + complete_request = Net::HTTP::Post.new(complete_uri) + complete_request["Authorization"] = "Bearer #{SLACK_TOKEN}" + complete_request["Content-Type"] = "application/json; charset=utf-8" + + payload = { + files: [{ id: file_id, title: file_title }], + channel_id: channel_id, + } + payload[:initial_comment] = initial_comment if initial_comment + payload[:thread_ts] = thread_ts if thread_ts + + complete_request.body = payload.to_json + + complete_http = Net::HTTP.new(complete_uri.host, complete_uri.port) + complete_http.use_ssl = true + complete_response = complete_http.request(complete_request) + + unless complete_response.is_a?(Net::HTTPSuccess) + raise "Slack API (files.completeUploadExternal) failed: #{complete_response.code} #{complete_response.body}" + end + + complete_response_json = JSON.parse(complete_response.body) + unless complete_response_json["ok"] + # Specific error handling for common issues + if complete_response_json["error"] == "invalid_channel" + UI.error("Error: Invalid SLACK_CHANNEL_ID: '#{channel_id}'. Please verify the channel ID.") + elsif complete_response_json["error"] == "channel_not_found" + UI.error("Error: Channel '#{channel_id}' not found. Ensure the bot is invited or the ID is correct.") + end + raise "Slack API Error (files.completeUploadExternal): #{complete_response_json["error"]} - #{complete_response_json["response_metadata"]&.[]("messages")&.join(", ")}" + end + + # Expecting an array of file objects + final_file_info = complete_response_json["files"]&.first + unless final_file_info + raise "Upload completed but no file information returned in response: #{complete_response.body}" + end + report_success("Successfully uploaded and shared #{file_name} (ID: #{final_file_info["id"]}) to Slack channel #{channel_id}") + end + + return final_file_info # Return the first file object on success + rescue JSON::ParserError => e + report_error("Failed to parse Slack API response.", "Error: #{e.message}", "Slack Upload Failed") + return false + rescue => e + # Include backtrace for better debugging + report_error("Error during Slack upload process: #{e.message}", e.backtrace.join("\n"), "Slack Upload Failed") + return false + end + end + end +end diff --git a/app/ios/OpenPassport/Info.plist b/app/ios/OpenPassport/Info.plist index 9cad2fbc7..907b17fc9 100644 --- a/app/ios/OpenPassport/Info.plist +++ b/app/ios/OpenPassport/Info.plist @@ -25,7 +25,7 @@ CFBundleSignature ???? CFBundleVersion - $(CURRENT_PROJECT_VERSION) + 111 LSApplicationCategoryType LSRequiresIPhoneOS diff --git a/app/ios/Podfile b/app/ios/Podfile index 9c50642fb..0bcb6b9a6 100644 --- a/app/ios/Podfile +++ b/app/ios/Podfile @@ -1,4 +1,5 @@ use_frameworks! +require 'tmpdir' # Resolve react_native_pods.rb with node to allow for hoisting require Pod::Executable.execute_command('node', ['-p', @@ -9,7 +10,7 @@ require Pod::Executable.execute_command('node', ['-p', project 'Self.xcodeproj' -platform :ios, '15.0' +platform :ios, '15.0' if !ENV['ACT'] prepare_react_native_project! @@ -98,12 +99,40 @@ target 'Self' do # update QKCutoutView.swift to hide OCR border qkCutoutView = 'Pods/QKMRZScanner/QKMRZScanner/QKCutoutView.swift' if File.exist?(qkCutoutView) - text = File.read(qkCutoutView) - # Comment out the line containing "addBorderAroundCutout()" - new_text = text.gsub(/^(\s*addBorderAroundCutout\s*\(\s*\))/, '// \1') - File.open(qkCutoutView, "w") { |file| file.puts new_text } + puts "Adding build phase script to patch QKCutoutView.swift" + phase_name = "Patch QKCutoutView to hide border" + + # Find the QKMRZScanner target + qkmrz_target = installer.pods_project.targets.find { |t| t.name == 'QKMRZScanner' } + + if qkmrz_target + # Check if the phase already exists to avoid duplicates + unless qkmrz_target.shell_script_build_phases.any? { |bp| bp.name == phase_name } + # Add a build phase that will patch the file during build time + phase = qkmrz_target.new_shell_script_build_phase(phase_name) + phase.shell_script = <<~SCRIPT + QKCUTOUT_PATH="${PODS_TARGET_SRCROOT}/QKMRZScanner/QKCutoutView.swift" + if [ -f "$QKCUTOUT_PATH" ]; then + # Use sed to comment out the line with addBorderAroundCutout + sed -i '' 's/^\\(\\s*addBorderAroundCutout\\s*(.*)\\)/\\/\\/\\1/' "$QKCUTOUT_PATH" + echo "Successfully patched QKCutoutView.swift to hide border" + else + echo "Warning: Could not find QKCutoutView.swift at $QKCUTOUT_PATH" + fi + SCRIPT + end + else + puts "Warning: Could not find QKMRZScanner target to add build phase" + end else puts "Warning: Could not find QKCutoutView.swift at #{qkCutoutView}" end + + # Disable code signing for Pod targets to avoid conflicts with main app signing + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO' + end + end end end \ No newline at end of file diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index de70d23e0..13630a92b 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -1627,7 +1627,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNSentry (6.8.0): + - RNSentry (6.10.0): - DoubleConversion - glog - RCT-Folly (= 2024.01.01.00) @@ -1647,17 +1647,17 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - Sentry/HybridSDK (= 8.45.0) + - Sentry/HybridSDK (= 8.48.0) - Yoga - RNSVG (15.11.1): - React-Core - segment-analytics-react-native (2.20.3): - React-Core - sovran-react-native - - Sentry (8.45.0): - - Sentry/Core (= 8.45.0) - - Sentry/Core (8.45.0) - - Sentry/HybridSDK (8.45.0) + - Sentry (8.48.0): + - Sentry/Core (= 8.48.0) + - Sentry/Core (8.48.0) + - Sentry/HybridSDK (8.48.0) - SentryPrivate (8.21.0) - SocketRocket (0.7.0) - sovran-react-native (1.1.3): @@ -1960,93 +1960,92 @@ SPEC CHECKSUMS: GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 lottie-ios: a881093fab623c467d3bce374367755c272bdd59 - lottie-react-native: db03203d873afcbb6e0cea6a262a88d95e64a98f + lottie-react-native: 3ffec00c889acded6057766c99adf8eaced7790c NFCPassportReader: e931c61c189e08a4b4afa0ed4014af19eab2f129 OpenSSL-Universal: 84efb8a29841f2764ac5403e0c4119a28b713346 QKMRZParser: 6b419b6f07d6bff6b50429b97de10846dc902c29 QKMRZScanner: cf2348fd6ce441e758328da4adf231ef2b51d769 - RCT-Folly: 4464f4d875961fce86008d45f4ecf6cef6de0740 + RCT-Folly: 34124ae2e667a0e5f0ea378db071d27548124321 RCTDeprecation: 726d24248aeab6d7180dac71a936bbca6a994ed1 RCTRequired: a94e7febda6db0345d207e854323c37e3a31d93b RCTTypeSafety: 28e24a6e44f5cbf912c66dde6ab7e07d1059a205 React: c2830fa483b0334bda284e46a8579ebbe0c5447e React-callinvoker: 4aecde929540c26b841a4493f70ebf6016691eb8 - React-Core: 1e3c04337857fa7fb7559f73f6f29a2a83a84b9c - React-CoreModules: 9fac2d31803c0ed03e4ddaa17f1481714f8633a5 - React-cxxreact: c72a7a8066fc4323ea85a3137de50c8a10a69794 + React-Core: 65374ea054f3f00eaa3c8bb5e989cb1ba8128844 + React-CoreModules: f53e0674e1747fa41c83bc970e82add97b14ad87 + React-cxxreact: bb77e88b645c5378ecd0c30c94f965a8294001d8 React-debug: 7e346b6eeacd2ee1118a0ee7d39f613b428b4be8 - React-defaultsnativemodule: e40e760aa97a7183d5f5a8174e44026673c4b995 - React-domnativemodule: 9fef73afd600e7c7d7f540d82532a113830bbdda - React-Fabric: dcd7ec3ea4da022b6c3f025e2567c9860ff1f760 - React-FabricComponents: 7e67af984cab1d6d1c02aae4a62933abc1baa5d3 - React-FabricImage: 77ca01a0a2bca3e1d39967220d7af7e3de033c9f + React-defaultsnativemodule: 4f1e9236c048fce31ebaf2c9c59ad7e76fb971a1 + React-domnativemodule: 0d0e04cd8a68f3984b7b15aada7ff531dfc3c3bd + React-Fabric: fa636eabfe3c8a3af3a9bface586956e90619ebf + React-FabricComponents: 52382f668a934df9cef21a7893beffbe0e2b2f5e + React-FabricImage: 69b745c0231d9360180f5e411370c6fb0c3cb546 React-featureflags: 4c45b3c06f9a124d2598aff495bfc59470f40597 - React-featureflagsnativemodule: d37e4fe27bd4f541d6d46f05e899345018067314 - React-graphics: a2e6209991a191c94405a234460e05291fa986b9 - React-idlecallbacksnativemodule: fa07e0af59ec6c950b2156b14c73c7fce4d0a663 - React-ImageManager: 17772f78d93539a1a10901b5f537031772fa930c + React-featureflagsnativemodule: 110c225191b3bca92694d36303385c2c299c12e5 + React-graphics: eb61d404819486a2d9335c043a967a0c4b8ca743 + React-idlecallbacksnativemodule: ca6930a17eaae01591610c87b19dbd90515f54a1 + React-ImageManager: 6652c4cc3de260b5269d58277de383cacd53a234 React-jsc: 4d3352be620f3fe2272238298aaccc9323b01824 - React-jserrorhandler: 62af5111f6444688182a5850d4b584cbc0c5d6a8 - React-jsi: 490deef195fd3f01d57dc89dda8233a84bd54b83 - React-jsiexecutor: 13bcb5e11822b2a6b69dbb175a24a39e24a02312 - React-jsinspector: 6961a23d4c11b72f3cbeb5083b0b18cc22bc48a1 - React-jsitracing: dab78a74a581f63320604c9de4ab9039209e0501 - React-logger: d79b704bf215af194f5213a6b7deec50ba8e6a9b - React-Mapbuffer: 42c779748af341935a63ad8831723b8cb1e97830 - React-microtasksnativemodule: 744f7e26200ea3976fef8453101cefcc08756008 - react-native-biometrics: 352e5a794bfffc46a0c86725ea7dc62deb085bdc - react-native-cloud-storage: 4c68bc6025c3624164461e15231efb28576f78a8 - react-native-get-random-values: 21325b2244dfa6b58878f51f9aa42821e7ba3d06 - react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac - react-native-nfc-manager: 5213321cf6c18d879c8092c0bf56806b771ec5ac - react-native-quick-crypto: 07fd0ba954a08d8c6b4cd1ff8b1fd91df2b650d6 - react-native-safe-area-context: 849d7df29ecb2a7155c769c0b76849ba952c2aa3 + React-jserrorhandler: 552c5fcd2ee64307c568734b965ea082e1be25cf + React-jsi: b187c826e5bda25afb36ede4c54c146cd50c9d6c + React-jsiexecutor: ac8478b6c5f53bcf411a66bf4461e923dafeb0bd + React-jsinspector: a82cfe0794b831d6e36cf0c8c07da56a0aaa1282 + React-jsitracing: e512a1023a25de831b51be1c773caa6036125a44 + React-logger: 80d87daf2f98bf95ab668b79062c1e0c3f0c2f8a + React-Mapbuffer: b2642edd9be75d51ead8cda109c986665eae09cf + React-microtasksnativemodule: 7ebf131e1792a668004d2719a36da0ff8d19c43c + react-native-biometrics: 43ed5b828646a7862dbc7945556446be00798e7d + react-native-cloud-storage: 74d1f1456d714e0fca6d10c7ab6fe9a52ba203b6 + react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba + react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 + react-native-nfc-manager: a280ef94cd4871a471b052f0dc70381cf1223049 + react-native-quick-crypto: a7cc5f870f270488fee9047b56e481de85c32e33 + react-native-safe-area-context: 3e33e7c43c8b74dba436a5a32651cb8d7064c740 React-nativeconfig: 31072ab0146e643594f6959c7f970a04b6c9ddd0 - React-NativeModulesApple: 5df767d9a2197ac25f4d8dd2d4ae1af3624022e2 + React-NativeModulesApple: 4ffcab4cdf34002540799bffbedd6466e8023c3a React-perflogger: 59e1a3182dca2cee7b9f1f7aab204018d46d1914 - React-performancetimeline: 3d70a278cc3344def506e97aff3640e658656110 + React-performancetimeline: 2bf8625ff44f482cba84e48e4ab21dee405d68cd React-RCTActionSheet: d80e68d3baa163e4012a47c1f42ddd8bcd9672cc - React-RCTAnimation: bde981f6bd7f8493696564da9b3bd05721d3b3cc - React-RCTAppDelegate: bc9c02d6dd4d162e3e1850283aba81bd246fc688 - React-RCTBlob: e492d54533e61a81f2601494a6f393b3e15e33b9 - React-RCTFabric: 4556aa70bd55b48d793cfb87e80d687c164298e2 - React-RCTImage: 90448d2882464af6015ed57c98f463f8748be465 - React-RCTLinking: 1bd95d0a704c271d21d758e0f0388cced768d77d - React-RCTNetwork: 218af6e63eb9b47935cc5a775b7a1396cf10ff91 - React-RCTSettings: e10b8e42b0fce8a70fbf169de32a2ae03243ef6b - React-RCTText: e7bf9f4997a1a0b45c052d4ad9a0fe653061cf29 - React-RCTVibration: 5b70b7f11e48d1c57e0d4832c2097478adbabe93 + React-RCTAnimation: 051f0781709c5ed80ba8aa2b421dfb1d72a03162 + React-RCTAppDelegate: 99345256dcceddcacab539ff8f56635de6a2f551 + React-RCTBlob: e949797c162421e363f93bfd8b546b7e632ba847 + React-RCTFabric: 396093d9aeee4bd3a6021ec6df8ed012f78763ef + React-RCTImage: b73149c0cd54b641dba2d6250aaf168fee784d9f + React-RCTLinking: 23e519712285427e50372fbc6e0265d422abf462 + React-RCTNetwork: a5d06d122588031989115f293654b13353753630 + React-RCTSettings: 87d03b5d94e6eadd1e8c1d16a62f790751aafb55 + React-RCTText: 75e9dd39684f4bcd1836134ac2348efaca7437b3 + React-RCTVibration: 033c161fe875e6fa096d0d9733c2e2501682e3d4 React-rendererconsistency: 35cef4bc4724194c544b6e5e2bd9b3f7aff52082 - React-rendererdebug: 9b1a6a2d4f8086a438f75f28350ccba16b7b706a + React-rendererdebug: 4e801e9f8d16d21877565dca2845a2e56202b8c6 React-rncore: 2c7c94d6e92db0850549223eb2fa8272e0942ac2 - React-RuntimeApple: 22397aca29a0c9be681db02c68416e508a381ef1 - React-RuntimeCore: a6d413611876d8180a5943b80cba3cefdf95ad5f + React-RuntimeApple: 0f661760cfcfa5d9464f7e05506874643e88fc2d + React-RuntimeCore: 1d0fcc0eb13807818e79ccaf48915596f0f5f0e6 React-runtimeexecutor: ea90d8e3a9e0f4326939858dafc6ab17c031a5d3 - React-runtimescheduler: e041df0539ad8a8a370e3507c39a9ab0571bb848 - React-utils: 768a7eb396b7df37aa19389201652eac613490cd - ReactCodegen: c53f8a0fa088739ee9929820feec1508043c7e6c - ReactCommon: 03d2d48fcd1329fe3bc4e428a78a0181b68068c2 - RNCAsyncStorage: 03861ec2e1e46b20e51963c62c51dc288beb7c43 - RNCClipboard: 60fed4b71560d7bfe40e9d35dea9762b024da86d - RNDeviceInfo: feea80a690d2bde1fe51461cf548039258bd03f2 - RNGestureHandler: 5639cd6112a3aa3bebc3871e3bf4e83940e20f6f - RNGoogleSignin: ee2938633d996756819e3212c8e0f7f696b380d0 - RNKeychain: bfe3d12bf4620fe488771c414530bf16e88f3678 - RNLocalize: 06991b9c31e7a898a9fa6ddb204ce0f53a967248 - RNReactNativeHapticFeedback: cba92e59f56506f6058d261dc85986012b2c5032 - RNScreens: d2a8ff4833a42f4eeadaea244f0bd793301b8810 - RNSentry: 7f68d46fd7f2315484bc1bdf54184d5fab48db5d - RNSVG: 669ed128ab9005090c612a0d627dbecb6ab5c76f - segment-analytics-react-native: d57ed4971cbb995706babf29215ebdbf242ecdab - Sentry: f7c0c4b82e2f7e5909a660544dfaff84e35d5e03 + React-runtimescheduler: 6b33edee8c830c7926670df4232d51f4f6a82795 + React-utils: 7198bd077f07ce8f9263c05bf610da6e251058ad + ReactCodegen: a2d336e0bec3d2f45475df55e7a02cc4e4c19623 + ReactCommon: b02a50498cb1071cd793044ddbd5d2b5f4db0a34 + RNCAsyncStorage: af7b591318005069c3795076addc83a4dd5c0a2e + RNCClipboard: 4abb037e8fe3b98a952564c9e0474f91c492df6d + RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047 + RNGestureHandler: 9c3877d98d4584891b69d16ebca855ac46507f4d + RNGoogleSignin: b8760528f2a7cbe157ecfdcc13bdb7d2745c9389 + RNKeychain: bbe2f6d5cc008920324acb49ef86ccc03d3b38e4 + RNLocalize: 15463c4d79c7da45230064b4adcf5e9bb984667e + RNReactNativeHapticFeedback: e19b9b2e2ecf5593de8c4ef1496e1e31ae227514 + RNScreens: b7e8d29c6be98f478bc3fb4a97cc770aa9ba7509 + RNSentry: c462461c0a5aaba206265f1f3db01b237cd33239 + RNSVG: 46769c92d1609e617dbf9326ad8a0cff912d0982 + segment-analytics-react-native: 6f98edf18246782ee7428c5380c6519a3d2acf5e + Sentry: 1ca8405451040482877dcd344dfa3ef80b646631 SentryPrivate: d651efb234cf385ec9a1cdd3eff94b5e78a0e0fe SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d - sovran-react-native: eec37f82e4429f0e3661f46aaf4fcd85d1b54f60 + sovran-react-native: a3ad3f8ff90c2002b2aa9790001a78b0b0a38594 SwiftQRScanner: e85a25f9b843e9231dab89a96e441472fe54a724 SwiftyTesseract: 1f3d96668ae92dc2208d9842c8a59bea9fad2cbb Yoga: b05994d1933f507b0a28ceaa4fdb968dc18da178 -PODFILE CHECKSUM: 4083907fcd59614c8d3d8cef569079861356d36f +PODFILE CHECKSUM: 9eaf085590e4280f6aedd49c2efe960fbf2b4079 COCOAPODS: 1.16.2 - diff --git a/app/ios/Self.xcodeproj/project.pbxproj b/app/ios/Self.xcodeproj/project.pbxproj index cff1d2675..037535aaa 100644 --- a/app/ios/Self.xcodeproj/project.pbxproj +++ b/app/ios/Self.xcodeproj/project.pbxproj @@ -481,7 +481,7 @@ CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassportDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 111; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 5B29R5LYHQ; ENABLE_BITCODE = NO; @@ -619,7 +619,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassport.entitlements; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 111; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 5B29R5LYHQ; FRAMEWORK_SEARCH_PATHS = ( diff --git a/app/package.json b/app/package.json index 97d351249..5962ed6db 100644 --- a/app/package.json +++ b/app/package.json @@ -6,22 +6,40 @@ "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:build-apk": "yarn reinstall && cd ./android && ./gradlew clean assembleRelease && cd ..", + "android:fastlane-debug": "yarn reinstall && bundle exec fastlane --verbose android internal_test", + "bump-version:major": "npm version major && yarn sync-versions", + "bump-version:minor": "npm version minor && yarn sync-versions", + "bump-version:patch": "npm version patch && yarn sync-versions", "clean": "watchman watch-del-all && rm -rf node_modules ios/Pods ios/build android/app/build android/build .yarn/cache ios/.xcode.env.local", + "clean:android": "rm -rf android/app/build android/build", + "clean:ios": "rm -rf ios/Pods ios/build", "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 clean:android && FORCE_UPLOAD_LOCAL_DEV=true bundle exec fastlane android deploy --verbose", + "force-local-upload-deploy:ios": "yarn clean:ios && 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 clean:android && FORCE_UPLOAD_LOCAL_DEV=true bundle exec fastlane android internal_test --verbose", + "force-local-upload-test:ios": "yarn clean:ios && FORCE_UPLOAD_LOCAL_DEV=true bundle exec fastlane ios internal_test --verbose", "ia": "yarn install-app", - "install-app": "cd ../common && yarn && cd ../app && yarn && cd ios && pod install && cd .. && yarn clean:xcode-env-local", + "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:setup": "cd ../common && yarn && cd ../app && yarn && 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 .", "nice": "yarn lint:fix && yarn fmt:fix", "reinstall": "yarn clean && yarn install && yarn install-app", "start": "watchman watch-del-all && react-native start", + "sync-versions": "bundle exec fastlane ios sync_version && bundle exec fastlane android sync_version", + "tag:release": "node scripts/tag.js release", + "tag:remove": "node scripts/tag.js remove", "test": "jest --passWithNoTests" }, "dependencies": { @@ -37,7 +55,7 @@ "@react-navigation/native-stack": "^7.2.0", "@segment/analytics-react-native": "^2.20.3", "@segment/sovran-react-native": "^1.1.3", - "@sentry/react-native": "^6.8.0", + "@sentry/react-native": "^6.10.0", "@stablelib/cbor": "^2.0.1", "@tamagui/config": "1.110.0", "@tamagui/lucide-icons": "1.110.0", diff --git a/app/scripts/tag.js b/app/scripts/tag.js new file mode 100644 index 000000000..40a2f78bc --- /dev/null +++ b/app/scripts/tag.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +// Get package version +function getVersion() { + const packageJson = JSON.parse( + fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8'), + ); + return packageJson.version; +} + +// Check if working directory is clean +function isWorkingDirectoryClean() { + try { + const status = execSync('git status --porcelain').toString(); + return status.trim() === ''; + } catch (error) { + console.error('Error checking git status:', error.message); + return false; + } +} + +// Create an empty commit +function createEmptyCommit(version) { + try { + execSync(`git commit --allow-empty -m "chore: release v${version}"`); + console.log(`Created empty commit for v${version}`); + } catch (error) { + console.error('Error creating commit:', error.message); + process.exit(1); + } +} + +// Create git tag +function createTag(version) { + try { + execSync(`git tag v${version}`); + console.log(`Created tag v${version}`); + } catch (error) { + console.error('Error creating tag:', error.message); + process.exit(1); + } +} + +// Push tag to remote +function pushTag(version) { + try { + execSync(`git push origin v${version}`); + console.log(`Pushed tag v${version} to remote`); + } catch (error) { + console.error('Error pushing tag:', error.message); + process.exit(1); + } +} + +// Remove tag locally and from remote +function removeTag(version) { + try { + execSync(`git tag -d v${version}`); + execSync(`git push origin :refs/tags/v${version}`); + console.log(`Removed tag v${version}`); + } catch (error) { + console.error('Error removing tag:', error.message); + process.exit(1); + } +} + +// Main function to handle commands +function main() { + const command = process.argv[2]; + const version = getVersion(); + + switch (command) { + case 'commit': + if (!isWorkingDirectoryClean()) { + console.error( + 'Error: Working directory is not clean. Please commit or stash changes first.', + ); + process.exit(1); + } + createEmptyCommit(version); + break; + + case 'create': + createTag(version); + break; + + case 'push': + pushTag(version); + break; + + case 'remove': + removeTag(version); + break; + + case 'release': + if (!isWorkingDirectoryClean()) { + console.error( + 'Error: Working directory is not clean. Please commit or stash changes first.', + ); + process.exit(1); + } + createEmptyCommit(version); + createTag(version); + pushTag(version); + break; + + default: + console.log('Usage: node tag.js [commit|create|push|remove|release]'); + process.exit(1); + } +} + +main(); diff --git a/app/yarn.lock b/app/yarn.lock index ef43d8d8c..537e3725d 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -2915,10 +2915,10 @@ __metadata: languageName: node linkType: hard -"@sentry/babel-plugin-component-annotate@npm:3.2.0": - version: 3.2.0 - resolution: "@sentry/babel-plugin-component-annotate@npm:3.2.0" - checksum: 10c0/f9ca9c5f64b86e129bb9bb4101d16c6e274b915d415279b1a09d2b80513c7280f5802e17b8a991674390d68cfa48d5033055e1632779f82d92bf9466ee2428a6 +"@sentry/babel-plugin-component-annotate@npm:3.2.2": + version: 3.2.2 + resolution: "@sentry/babel-plugin-component-annotate@npm:3.2.2" + checksum: 10c0/96835b165b6f7904eb74d117fed137f724490bc919940c740113eb2d8836de6ccd977b7ec056a0ca20712e6cdb19c30a9f515ec0028ecac70c7880f0a6b9a58d languageName: node linkType: hard @@ -2935,66 +2935,66 @@ __metadata: languageName: node linkType: hard -"@sentry/cli-darwin@npm:2.42.1": - version: 2.42.1 - resolution: "@sentry/cli-darwin@npm:2.42.1" +"@sentry/cli-darwin@npm:2.42.4": + version: 2.42.4 + resolution: "@sentry/cli-darwin@npm:2.42.4" conditions: os=darwin languageName: node linkType: hard -"@sentry/cli-linux-arm64@npm:2.42.1": - version: 2.42.1 - resolution: "@sentry/cli-linux-arm64@npm:2.42.1" +"@sentry/cli-linux-arm64@npm:2.42.4": + version: 2.42.4 + resolution: "@sentry/cli-linux-arm64@npm:2.42.4" conditions: (os=linux | os=freebsd) & cpu=arm64 languageName: node linkType: hard -"@sentry/cli-linux-arm@npm:2.42.1": - version: 2.42.1 - resolution: "@sentry/cli-linux-arm@npm:2.42.1" +"@sentry/cli-linux-arm@npm:2.42.4": + version: 2.42.4 + resolution: "@sentry/cli-linux-arm@npm:2.42.4" conditions: (os=linux | os=freebsd) & cpu=arm languageName: node linkType: hard -"@sentry/cli-linux-i686@npm:2.42.1": - version: 2.42.1 - resolution: "@sentry/cli-linux-i686@npm:2.42.1" +"@sentry/cli-linux-i686@npm:2.42.4": + version: 2.42.4 + resolution: "@sentry/cli-linux-i686@npm:2.42.4" conditions: (os=linux | os=freebsd) & (cpu=x86 | cpu=ia32) languageName: node linkType: hard -"@sentry/cli-linux-x64@npm:2.42.1": - version: 2.42.1 - resolution: "@sentry/cli-linux-x64@npm:2.42.1" +"@sentry/cli-linux-x64@npm:2.42.4": + version: 2.42.4 + resolution: "@sentry/cli-linux-x64@npm:2.42.4" conditions: (os=linux | os=freebsd) & cpu=x64 languageName: node linkType: hard -"@sentry/cli-win32-i686@npm:2.42.1": - version: 2.42.1 - resolution: "@sentry/cli-win32-i686@npm:2.42.1" +"@sentry/cli-win32-i686@npm:2.42.4": + version: 2.42.4 + resolution: "@sentry/cli-win32-i686@npm:2.42.4" conditions: os=win32 & (cpu=x86 | cpu=ia32) languageName: node linkType: hard -"@sentry/cli-win32-x64@npm:2.42.1": - version: 2.42.1 - resolution: "@sentry/cli-win32-x64@npm:2.42.1" +"@sentry/cli-win32-x64@npm:2.42.4": + version: 2.42.4 + resolution: "@sentry/cli-win32-x64@npm:2.42.4" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@sentry/cli@npm:2.42.1": - version: 2.42.1 - resolution: "@sentry/cli@npm:2.42.1" +"@sentry/cli@npm:2.42.4": + version: 2.42.4 + resolution: "@sentry/cli@npm:2.42.4" dependencies: - "@sentry/cli-darwin": "npm:2.42.1" - "@sentry/cli-linux-arm": "npm:2.42.1" - "@sentry/cli-linux-arm64": "npm:2.42.1" - "@sentry/cli-linux-i686": "npm:2.42.1" - "@sentry/cli-linux-x64": "npm:2.42.1" - "@sentry/cli-win32-i686": "npm:2.42.1" - "@sentry/cli-win32-x64": "npm:2.42.1" + "@sentry/cli-darwin": "npm:2.42.4" + "@sentry/cli-linux-arm": "npm:2.42.4" + "@sentry/cli-linux-arm64": "npm:2.42.4" + "@sentry/cli-linux-i686": "npm:2.42.4" + "@sentry/cli-linux-x64": "npm:2.42.4" + "@sentry/cli-win32-i686": "npm:2.42.4" + "@sentry/cli-win32-x64": "npm:2.42.4" https-proxy-agent: "npm:^5.0.0" node-fetch: "npm:^2.6.7" progress: "npm:^2.0.3" @@ -3017,7 +3017,7 @@ __metadata: optional: true bin: sentry-cli: bin/sentry-cli - checksum: 10c0/2e1cfd587c7e6f4a5f5e056043eb3205ae6a716b9d1a1b690857fc5905df1785a903103510e95645a23274605ac560061183d294815c3ec45bdb0ca77726f101 + checksum: 10c0/e3900743803470874228a7d9b02f54e7973b01c89d433cd03d7d6fe71ae44df3f4420743fcae821613a572978ab1ff6fa397ab447a320bbe021bee333f04f397 languageName: node linkType: hard @@ -3028,13 +3028,13 @@ __metadata: languageName: node linkType: hard -"@sentry/react-native@npm:^6.8.0": - version: 6.8.0 - resolution: "@sentry/react-native@npm:6.8.0" +"@sentry/react-native@npm:^6.10.0": + version: 6.10.0 + resolution: "@sentry/react-native@npm:6.10.0" dependencies: - "@sentry/babel-plugin-component-annotate": "npm:3.2.0" + "@sentry/babel-plugin-component-annotate": "npm:3.2.2" "@sentry/browser": "npm:8.54.0" - "@sentry/cli": "npm:2.42.1" + "@sentry/cli": "npm:2.42.4" "@sentry/core": "npm:8.54.0" "@sentry/react": "npm:8.54.0" "@sentry/types": "npm:8.54.0" @@ -3048,7 +3048,7 @@ __metadata: optional: true bin: sentry-expo-upload-sourcemaps: scripts/expo-upload-sourcemaps.js - checksum: 10c0/6bcf9b9ca5cd855740c3300ee407421e055615f05e1bb3ecb5ef308afea873bb3795329610a2e8bccfa99343d1a37fa01627207064c44ca85c603f8412b01eb2 + checksum: 10c0/792cbb4437edea18f99bd0a5d1106523321556737758a88c742cdbdaf111eb344c736dfa4ef4a6d43213f56153714a2184dddde8d625c0932de36c9de5d32d29 languageName: node linkType: hard @@ -11964,7 +11964,7 @@ __metadata: "@react-navigation/native-stack": "npm:^7.2.0" "@segment/analytics-react-native": "npm:^2.20.3" "@segment/sovran-react-native": "npm:^1.1.3" - "@sentry/react-native": "npm:^6.8.0" + "@sentry/react-native": "npm:^6.10.0" "@stablelib/cbor": "npm:^2.0.1" "@tamagui/config": "npm:1.110.0" "@tamagui/lucide-icons": "npm:1.110.0"