diff --git a/.github/actions/mobile-setup/action.yml b/.github/actions/mobile-setup/action.yml index 4fb15a340..e1419e5d1 100644 --- a/.github/actions/mobile-setup/action.yml +++ b/.github/actions/mobile-setup/action.yml @@ -47,11 +47,55 @@ runs: with: node-version: ${{ inputs.node_version }} + - name: Configure bundler + shell: bash + run: | + cd ${{ inputs.app_path }} + bundle config set --local path 'vendor/bundle' + bundle config set --local deployment 'true' + echo "โœ… Bundler configured for strict mode (deployment=true)" + - name: Install app dependencies shell: bash run: | cd ${{ inputs.app_path }} + + # Configure Yarn corepack enable yarn set version 4.6.0 - yarn install + + echo "๐Ÿ“ฆ Installing JavaScript dependencies with strict lock file..." + if ! yarn install --immutable; then + echo "" + echo "โŒ ERROR: yarn.lock is out of date!" + echo "" + echo "This happens when package.json was modified but yarn.lock wasn't updated." + echo "" + echo "To fix this:" + echo " 1. Run 'yarn install' locally in the app directory" + echo " 2. Commit the updated yarn.lock file" + echo " 3. Push your changes" + echo "" + echo "This ensures everyone has the exact same dependency versions." + exit 1 + fi + + # Run mobile-specific installation yarn install-app:mobile-deploy + + # Install Ruby gems with bundler (respecting cache) + echo "๐Ÿ“ฆ Installing Ruby gems with strict lock file..." + if ! bundle install --jobs 4 --retry 3; then + echo "" + echo "โŒ ERROR: Gemfile.lock is out of date!" + echo "" + echo "This happens when Gemfile was modified but Gemfile.lock wasn't updated." + echo "" + echo "To fix this:" + echo " 1. Run 'bundle install' locally in the app directory" + echo " 2. Commit the updated Gemfile.lock file" + echo " 3. Push your changes" + echo "" + echo "This ensures everyone has the exact same gem versions." + exit 1 + fi diff --git a/.github/workflows/mobile-deploy.yml b/.github/workflows/mobile-deploy.yml index ba78e37a4..194940c20 100644 --- a/.github/workflows/mobile-deploy.yml +++ b/.github/workflows/mobile-deploy.yml @@ -8,6 +8,13 @@ env: ANDROID_API_LEVEL: 35 ANDROID_NDK_VERSION: 26.1.10909125 + # Cache versioning - increment these to bust caches when needed + GH_CACHE_VERSION: v1 # Global cache version + GH_YARN_CACHE_VERSION: v1 # Yarn-specific cache version + GH_GEMS_CACHE_VERSION: v1 # Ruby gems cache version + GH_PODS_CACHE_VERSION: v1 # CocoaPods cache version + GH_GRADLE_CACHE_VERSION: v1 # Gradle cache version + # Path configuration WORKSPACE: ${{ github.workspace }} APP_PATH: ${{ github.workspace }}/app @@ -36,6 +43,11 @@ on: - ios - android - both + test_mode: + description: "Test mode (skip upload to stores)" + required: false + type: boolean + default: false jobs: build-ios: @@ -52,6 +64,9 @@ jobs: echo "๐Ÿš€ Mobile deployment is enabled - proceeding with iOS build" fi + - uses: actions/checkout@v4 + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' + - name: Set up Xcode if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' uses: maxim-lobanov/setup-xcode@v1 @@ -59,8 +74,64 @@ jobs: # # some cocoapods won't compile with xcode 16.3 # xcode-version: "16.2" - - uses: actions/checkout@v4 - if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' + - name: Cache Yarn dependencies + id: yarn-cache + uses: actions/cache@v4 + with: + path: | + ${{ env.APP_PATH }}/node_modules + ~/.cache/yarn + key: ${{ runner.os }}-yarn-${{ env.GH_CACHE_VERSION }}-${{ env.GH_YARN_CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-${{ env.GH_CACHE_VERSION }}-${{ env.GH_YARN_CACHE_VERSION }}- + ${{ runner.os }}-yarn-${{ env.GH_CACHE_VERSION }}- + + - name: Cache Ruby gems + id: gems-cache + uses: actions/cache@v4 + with: + path: ${{ env.APP_PATH }}/vendor/bundle + key: ${{ runner.os }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-${{ hashFiles('app/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}- + ${{ runner.os }}-gems-${{ env.GH_CACHE_VERSION }}- + + - name: Cache CocoaPods + id: pods-cache + uses: actions/cache@v4 + with: + path: ${{ env.APP_PATH }}/ios/Pods + key: ${{ runner.os }}-pods-${{ env.GH_CACHE_VERSION }}-${{ env.GH_PODS_CACHE_VERSION }}-${{ hashFiles('app/ios/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-pods-${{ env.GH_CACHE_VERSION }}-${{ env.GH_PODS_CACHE_VERSION }}- + ${{ runner.os }}-pods-${{ env.GH_CACHE_VERSION }}- + + - name: Log cache status + run: | + echo "Cache hit results:" + echo "- Yarn cache hit: ${{ steps.yarn-cache.outputs.cache-hit }}" + echo "- Gems cache hit: ${{ steps.gems-cache.outputs.cache-hit }}" + echo "- Pods cache hit: ${{ steps.pods-cache.outputs.cache-hit }}" + + - name: Verify lock files are up to date + run: | + echo "๐Ÿ” Checking if lock files are in sync with dependency files..." + + # For yarn workspaces, yarn.lock is at root + if [ ! -f "${{ env.WORKSPACE }}/yarn.lock" ]; then + echo "โŒ ERROR: yarn.lock file is missing at workspace root!" + echo "Run 'yarn install' at the repository root and commit the yarn.lock file." + exit 1 + fi + + # Gemfile.lock is in app directory + if [ ! -f "${{ env.APP_PATH }}/Gemfile.lock" ]; then + echo "โŒ ERROR: Gemfile.lock file is missing!" + echo "Run 'bundle install' in the app directory and commit the Gemfile.lock file." + exit 1 + fi + + echo "โœ… Lock files exist" - name: Install Mobile Dependencies if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' @@ -310,8 +381,15 @@ jobs: echo "Identities in build.keychain:" security find-identity -v -p codesigning build.keychain || echo "Failed to find identities in build.keychain" echo "--- Starting Fastlane ---" - echo "๐Ÿš€ Uploading to App Store Connect/TestFlight..." - bundle exec fastlane ios internal_test --verbose + if [ "${{ github.event.inputs.test_mode }}" = "true" ]; then + echo "๐Ÿงช Running in TEST MODE - will skip upload to TestFlight" + bundle exec fastlane ios internal_test --verbose test_mode:true + else + echo "๐Ÿš€ Uploading to App Store Connect/TestFlight..." + bundle exec fastlane ios internal_test --verbose + fi + + # Version updates moved to separate job to avoid race conditions - name: Remove project.pbxproj updates we don't want to commit if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'android' @@ -369,6 +447,30 @@ jobs: commit_message: "incrementing ios build number for version ${{ env.VERSION }}" commit_paths: "./app/ios/OpenPassport/Info.plist ./app/ios/Self.xcodeproj/project.pbxproj" + - name: Monitor cache usage + if: always() + run: | + echo "๐Ÿ“Š Cache Size Report (iOS Build)" + echo "================================" + + if [ -d "${{ env.APP_PATH }}/node_modules" ]; then + NODE_SIZE=$(du -sh "${{ env.APP_PATH }}/node_modules" | cut -f1) + echo "Node modules: $NODE_SIZE" + fi + + if [ -d "${{ env.APP_PATH }}/vendor/bundle" ]; then + GEMS_SIZE=$(du -sh "${{ env.APP_PATH }}/vendor/bundle" | cut -f1) + echo "Ruby gems: $GEMS_SIZE" + fi + + if [ -d "${{ env.APP_PATH }}/ios/Pods" ]; then + PODS_SIZE=$(du -sh "${{ env.APP_PATH }}/ios/Pods" | cut -f1) + echo "CocoaPods: $PODS_SIZE" + fi + + echo "================================" + echo "๐Ÿ’ก GitHub Actions cache limit: 10GB per repository" + build-android: runs-on: ubuntu-latest if: github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'android' || github.event.inputs.platform == 'both') @@ -386,6 +488,75 @@ jobs: - uses: actions/checkout@v4 if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' + - name: Cache Yarn dependencies + id: yarn-cache + uses: actions/cache@v4 + with: + path: | + ${{ env.APP_PATH }}/node_modules + ~/.cache/yarn + key: ${{ runner.os }}-yarn-${{ env.GH_CACHE_VERSION }}-${{ env.GH_YARN_CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-${{ env.GH_CACHE_VERSION }}-${{ env.GH_YARN_CACHE_VERSION }}- + ${{ runner.os }}-yarn-${{ env.GH_CACHE_VERSION }}- + + - name: Cache Ruby gems + id: gems-cache + uses: actions/cache@v4 + with: + path: ${{ env.APP_PATH }}/vendor/bundle + key: ${{ runner.os }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-${{ hashFiles('app/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}- + ${{ runner.os }}-gems-${{ env.GH_CACHE_VERSION }}- + + - name: Cache Gradle dependencies + id: gradle-cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GRADLE_CACHE_VERSION }}-${{ hashFiles('app/android/**/*.gradle*', 'app/android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GRADLE_CACHE_VERSION }}- + ${{ runner.os }}-gradle-${{ env.GH_CACHE_VERSION }}- + + - name: Cache Android NDK + id: ndk-cache + uses: actions/cache@v4 + with: + path: ${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.ANDROID_NDK_VERSION }} + key: ${{ runner.os }}-ndk-${{ env.ANDROID_NDK_VERSION }} + + - name: Log cache status + run: | + echo "Cache hit results:" + echo "- Yarn cache hit: ${{ steps.yarn-cache.outputs.cache-hit }}" + echo "- Gems cache hit: ${{ steps.gems-cache.outputs.cache-hit }}" + echo "- Gradle cache hit: ${{ steps.gradle-cache.outputs.cache-hit }}" + echo "- NDK cache hit: ${{ steps.ndk-cache.outputs.cache-hit }}" + + - name: Verify lock files are up to date + run: | + echo "๐Ÿ” Checking if lock files are in sync with dependency files..." + + # For yarn workspaces, yarn.lock is at root + if [ ! -f "${{ env.WORKSPACE }}/yarn.lock" ]; then + echo "โŒ ERROR: yarn.lock file is missing at workspace root!" + echo "Run 'yarn install' at the repository root and commit the yarn.lock file." + exit 1 + fi + + # Gemfile.lock is in app directory + if [ ! -f "${{ env.APP_PATH }}/Gemfile.lock" ]; then + echo "โŒ ERROR: Gemfile.lock file is missing!" + echo "Run 'bundle install' in the app directory and commit the Gemfile.lock file." + exit 1 + fi + + echo "โœ… Lock files exist" + - name: Install Mobile Dependencies if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' uses: ./.github/actions/mobile-setup @@ -410,7 +581,7 @@ jobs: accept-android-sdk-licenses: true - name: Install NDK - if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' + if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' && steps.ndk-cache.outputs.cache-hit != 'true' run: | max_attempts=5 attempt=1 @@ -495,8 +666,15 @@ jobs: SLACK_ANNOUNCE_CHANNEL_NAME: ${{ secrets.SLACK_ANNOUNCE_CHANNEL_NAME }} run: | cd ${{ env.APP_PATH }} - echo "๐Ÿš€ Uploading to Google Play Internal Testing..." - bundle exec fastlane android internal_test --verbose + if [ "${{ github.event.inputs.test_mode }}" = "true" ]; then + echo "๐Ÿงช Running in TEST MODE - will skip upload to Play Store" + bundle exec fastlane android internal_test --verbose test_mode:true + else + echo "๐Ÿš€ Uploading to Google Play Internal Testing..." + bundle exec fastlane android internal_test --verbose + fi + + # Version updates moved to separate job to avoid race conditions - name: Get version from package.json if: github.event_name == 'workflow_dispatch' && github.event.inputs.platform != 'ios' @@ -511,3 +689,113 @@ jobs: with: commit_message: "incrementing android build version for version ${{ env.VERSION }}" commit_paths: "./app/android/app/build.gradle" + + - name: Monitor cache usage + if: always() + run: | + echo "๐Ÿ“Š Cache Size Report (Android Build)" + echo "====================================" + + if [ -d "${{ env.APP_PATH }}/node_modules" ]; then + NODE_SIZE=$(du -sh "${{ env.APP_PATH }}/node_modules" | cut -f1) + echo "Node modules: $NODE_SIZE" + fi + + if [ -d "${{ env.APP_PATH }}/vendor/bundle" ]; then + GEMS_SIZE=$(du -sh "${{ env.APP_PATH }}/vendor/bundle" | cut -f1) + echo "Ruby gems: $GEMS_SIZE" + fi + + if [ -d "$HOME/.gradle/caches" ]; then + GRADLE_SIZE=$(du -sh "$HOME/.gradle/caches" | cut -f1) + echo "Gradle caches: $GRADLE_SIZE" + fi + + if [ -d "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.ANDROID_NDK_VERSION }}" ]; then + NDK_SIZE=$(du -sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.ANDROID_NDK_VERSION }}" | cut -f1) + echo "Android NDK: $NDK_SIZE" + fi + + echo "====================================" + echo "๐Ÿ’ก GitHub Actions cache limit: 10GB per repository" + + # Separate job to update version files after successful deployment + # This avoids race conditions when both iOS and Android run in parallel + update-version: + runs-on: ubuntu-latest + needs: [build-ios, build-android] + if: | + always() && + github.event.inputs.test_mode != 'true' && + (needs.build-ios.result == 'success' || needs.build-android.result == 'success') + env: + NODE_VERSION: 18 + APP_PATH: ${{ github.workspace }}/app + steps: + - uses: actions/checkout@v4 + with: + token: ${{ github.token }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Update package.json version + run: | + cd ${{ env.APP_PATH }} + + # Get current version from package.json + CURRENT_VERSION=$(node -p "require('./package.json').version") + + # Get new version from version.json (if it exists and has version field) + if [ -f version.json ] && grep -q '"version"' version.json; then + NEW_VERSION=$(node -pe 'require("./version.json").version' 2>/dev/null || echo "") + else + # Fallback: use current version from package.json + NEW_VERSION="$CURRENT_VERSION" + fi + + # Only update if versions differ + if [ "$CURRENT_VERSION" != "$NEW_VERSION" ] && [ -n "$NEW_VERSION" ]; then + echo "๐Ÿ“ฆ Updating package.json version:" + echo " From: v$CURRENT_VERSION" + echo " To: v$NEW_VERSION" + + # Use yarn to update package.json and the lockfile + yarn version --new-version "$NEW_VERSION" --no-git-tag-version -y + else + echo "โ„น๏ธ Version already up to date or no version field in version.json" + fi + + - name: Commit and push version files + run: | + cd ${{ github.workspace }} + + # Configure git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Check if there are any changes to commit + if git diff --quiet app/version.json app/package.json yarn.lock 2>/dev/null; then + echo "No changes to version files, skipping commit" + else + # Stage the changes + git add app/version.json app/package.json yarn.lock 2>/dev/null || true + + # Create commit message based on which platforms were deployed + COMMIT_MSG="chore: update version files after" + if [ "${{ needs.build-ios.result }}" = "success" ] && [ "${{ needs.build-android.result }}" = "success" ]; then + COMMIT_MSG="$COMMIT_MSG iOS and Android deployment" + elif [ "${{ needs.build-ios.result }}" = "success" ]; then + COMMIT_MSG="$COMMIT_MSG iOS deployment" + else + COMMIT_MSG="$COMMIT_MSG Android deployment" + fi + COMMIT_MSG="$COMMIT_MSG [skip ci]" + + # Commit and push + git commit -m "$COMMIT_MSG" + git push + echo "โœ… Committed version file changes" + fi diff --git a/app/Gemfile.lock b/app/Gemfile.lock index 4197a637e..6ae234a60 100644 --- a/app/Gemfile.lock +++ b/app/Gemfile.lock @@ -230,7 +230,6 @@ GEM 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) multi_json (1.15.0) @@ -241,14 +240,10 @@ GEM 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) - racc (1.8.1) rake (13.2.1) representable (3.2.0) declarative (< 0.1.0) @@ -308,7 +303,6 @@ DEPENDENCIES fastlane (~> 2.228.0) fastlane-plugin-increment_version_code fastlane-plugin-versioning_android - nokogiri (~> 1.18) RUBY VERSION ruby 3.2.7p253 diff --git a/app/fastlane/Fastfile b/app/fastlane/Fastfile index 085bf26a6..1bfdfcb77 100644 --- a/app/fastlane/Fastfile +++ b/app/fastlane/Fastfile @@ -58,20 +58,33 @@ platform :ios do end desc "Push a new build to TestFlight Internal Testing" - lane :internal_test do + lane :internal_test do |options| + test_mode = options[:test_mode] == true || options[:test_mode] == "true" + result = prepare_ios_build(prod_release: false) - upload_to_testflight( - api_key: result[:api_key], - distribute_external: true, - # Only external TestFlight groups are valid here - groups: ENV["IOS_TESTFLIGHT_GROUPS"].split(","), - changelog: "", - skip_waiting_for_build_processing: false, - ) if result[:should_upload] + if test_mode + UI.important("๐Ÿงช TEST MODE: Skipping TestFlight upload") + UI.success("โœ… Build completed successfully!") + UI.message("๐Ÿ“ฆ IPA path: #{result[:ipa_path]}") + else + upload_to_testflight( + api_key: result[:api_key], + distribute_external: true, + # Only external TestFlight groups are valid here + groups: ENV["IOS_TESTFLIGHT_GROUPS"].split(","), + changelog: "", + skip_waiting_for_build_processing: false, + ) if result[:should_upload] + end + + # Update deployment timestamp in version.json + if result[:should_upload] + Fastlane::Helpers.update_deployment_timestamp('ios') + end # Notify Slack about the new build - if ENV["SLACK_CHANNEL_ID"] + if ENV["SLACK_CHANNEL_ID"] && !test_mode deploy_source = Fastlane::Helpers.is_ci_environment? ? "GitHub Workflow" : "Local Deploy" Fastlane::Helpers.upload_file_to_slack( file_path: result[:ipa_path], @@ -79,6 +92,8 @@ platform :ios do initial_comment: "๐ŸŽ iOS v#{package_version} (Build #{result[:build_number]}) deployed to TestFlight via #{deploy_source}", title: "#{APP_NAME}-#{package_version}-#{result[:build_number]}.ipa", ) + elsif test_mode + UI.important("๐Ÿงช TEST MODE: Skipping Slack notification") else UI.important("Skipping Slack notification: SLACK_CHANNEL_ID not set.") end @@ -122,13 +137,16 @@ platform :ios do Fastlane::Helpers.verify_env_vars(required_env_vars) - # Get current build number without auto-incrementing - project = Xcodeproj::Project.open(ios_xcode_profile_path) - target = project.targets.first - config = target.build_configurations.first - build_number = config.build_settings["CURRENT_PROJECT_VERSION"] - - # Verify build number is higher than TestFlight (but don't auto-increment) + # Get build number from version.json and increment it + build_number = Fastlane::Helpers.bump_ios_build_number + + # Update Xcode project with new build number + increment_build_number( + build_number: build_number, + xcodeproj: "ios/#{ENV["IOS_PROJECT_NAME"]}.xcodeproj" + ) + + # Verify build number is higher than TestFlight Fastlane::Helpers.ios_verify_app_store_build_number(ios_xcode_profile_path) Fastlane::Helpers.ios_verify_provisioning_profile @@ -200,8 +218,8 @@ platform :android do end desc "Push a new build to Google Play Internal Testing" - lane :internal_test do - upload_android_build(track: "internal") + lane :internal_test do |options| + upload_android_build(track: "internal", test_mode: options[:test_mode]) end desc "Push a new build to Google Play Store" @@ -210,6 +228,7 @@ platform :android do end private_lane :upload_android_build do |options| + test_mode = options[:test_mode] == true || options[:test_mode] == "true" if local_development if ENV["ANDROID_KEYSTORE_PATH"].nil? ENV["ANDROID_KEYSTORE_PATH"] = Fastlane::Helpers.android_create_keystore(android_keystore_path) @@ -232,10 +251,14 @@ platform :android do Fastlane::Helpers.verify_env_vars(required_env_vars) - # Get current version code without auto-incrementing - content = File.read(android_gradle_file_path) - match = content.match(/versionCode\s+(\d+)/) - version_code = match ? match[1].to_i : 1 + # Get version code from version.json and increment it + version_code = Fastlane::Helpers.bump_android_build_number + + # Update build.gradle with new version code + increment_version_code( + version_code: version_code, + gradle_file_path: android_gradle_file_path.gsub("../", "") + ) # TODO: uncomment when we have the permissions to run this action # Fastlane::Helpers.android_verify_version_code(android_gradle_file_path) @@ -260,19 +283,30 @@ platform :android do ) 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 + if test_mode + UI.important("๐Ÿงช TEST MODE: Skipping Play Store upload") + UI.success("โœ… Build completed successfully!") + UI.message("๐Ÿ“ฆ AAB path: #{android_aab_path}") + else + 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 + end + + # Update deployment timestamp in version.json + if should_upload + Fastlane::Helpers.update_deployment_timestamp('android') + end # Notify Slack about the new build - if ENV["SLACK_CHANNEL_ID"] + if ENV["SLACK_CHANNEL_ID"] && !test_mode deploy_source = Fastlane::Helpers.is_ci_environment? ? "GitHub Workflow" : "Local Deploy" Fastlane::Helpers.upload_file_to_slack( file_path: android_aab_path, @@ -280,6 +314,8 @@ platform :android do initial_comment: "๐Ÿค– Android v#{package_version} (Build #{version_code}) deployed to #{target_platform} via #{deploy_source}", title: "#{APP_NAME}-#{package_version}-#{version_code}.aab", ) + elsif test_mode + UI.important("๐Ÿงช TEST MODE: Skipping Slack notification") else UI.important("Skipping Slack notification: SLACK_CHANNEL_ID not set.") end diff --git a/app/fastlane/helpers.rb b/app/fastlane/helpers.rb index 2ef4946d3..12b4e49f8 100644 --- a/app/fastlane/helpers.rb +++ b/app/fastlane/helpers.rb @@ -13,6 +13,7 @@ require_relative "helpers/ios" require_relative "helpers/android" require_relative "helpers/slack" +require_relative "helpers/version_manager" module Fastlane module Helpers @@ -20,6 +21,7 @@ module Helpers extend Fastlane::Helpers::IOS extend Fastlane::Helpers::Android extend Fastlane::Helpers::Slack + extend Fastlane::Helpers::VersionManager end end diff --git a/app/fastlane/helpers/version_manager.rb b/app/fastlane/helpers/version_manager.rb new file mode 100644 index 000000000..8a107fa5a --- /dev/null +++ b/app/fastlane/helpers/version_manager.rb @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: BUSL-1.1 +require 'json' +require 'time' + +module Fastlane + module Helpers + module VersionManager + extend self + + VERSION_FILE_PATH = File.expand_path('../../version.json', __dir__) + + def read_version_file + unless File.exist?(VERSION_FILE_PATH) + UI.user_error!("version.json not found at #{VERSION_FILE_PATH}") + end + + JSON.parse(File.read(VERSION_FILE_PATH)) + rescue JSON::ParserError => e + UI.user_error!("Failed to parse version.json: #{e.message}") + end + + def write_version_file(data) + File.write(VERSION_FILE_PATH, JSON.pretty_generate(data) + "\n") + UI.success("Updated version.json") + rescue => e + UI.user_error!("Failed to write version.json: #{e.message}") + end + + def get_current_version + # Version comes from package.json, not version.json + package_json_path = File.expand_path('../../package.json', __dir__) + package_data = JSON.parse(File.read(package_json_path)) + package_data['version'] + end + + def get_ios_build_number + data = read_version_file + data['ios']['build'] + end + + def get_android_build_number + data = read_version_file + data['android']['build'] + end + + def bump_ios_build_number + data = read_version_file + current = data['ios']['build'] + data['ios']['build'] = current + 1 + write_version_file(data) + UI.success("iOS build number bumped from #{current} to #{data['ios']['build']}") + data['ios']['build'] + end + + def bump_android_build_number + data = read_version_file + current = data['android']['build'] + data['android']['build'] = current + 1 + write_version_file(data) + UI.success("Android build number bumped from #{current} to #{data['android']['build']}") + data['android']['build'] + end + + def update_deployment_timestamp(platform) + unless %w[ios android].include?(platform) + UI.user_error!("Invalid platform: #{platform}. Must be 'ios' or 'android'") + end + + data = read_version_file + timestamp = Time.now.utc.iso8601 + + data[platform]['lastDeployed'] = timestamp + + write_version_file(data) + UI.success("Updated #{platform} deployment timestamp") + end + + def sync_build_numbers_to_native + data = read_version_file + version = get_current_version + + UI.message("Version #{version} (from package.json)") + UI.message("iOS build: #{data['ios']['build']}") + UI.message("Android build: #{data['android']['build']}") + + # Return the build numbers for use in Fastlane + { + ios: data['ios']['build'], + android: data['android']['build'] + } + end + end + end +end \ No newline at end of file diff --git a/app/scripts/mobile-deploy-confirm.cjs b/app/scripts/mobile-deploy-confirm.cjs index 928c2616e..4fe00b500 100755 --- a/app/scripts/mobile-deploy-confirm.cjs +++ b/app/scripts/mobile-deploy-confirm.cjs @@ -20,6 +20,7 @@ const SUPPORTED_PLATFORMS = Object.values(PLATFORMS); const FILE_PATHS = { PACKAGE_JSON: '../package.json', + VERSION_JSON: '../version.json', IOS_INFO_PLIST: '../ios/OpenPassport/Info.plist', IOS_PROJECT_PBXPROJ: '../ios/Self.xcodeproj/project.pbxproj', ANDROID_BUILD_GRADLE: '../android/app/build.gradle', @@ -239,15 +240,56 @@ function getAndroidVersion() { }; } +/** + * Reads version.json for build numbers and deployment history + * @returns {Object|null} Version data or null if not found + */ +function getVersionJsonData() { + const versionJsonPath = path.join(__dirname, FILE_PATHS.VERSION_JSON); + try { + const versionData = JSON.parse(fs.readFileSync(versionJsonPath, 'utf8')); + return versionData; + } catch (error) { + console.warn(`Warning: Could not read version.json: ${error.message}`); + return null; + } +} + +/** + * Formats time elapsed since last deployment + * @param {string} timestamp - ISO timestamp of last deployment + * @returns {string} Human-readable time elapsed + */ +function getTimeAgo(timestamp) { + if (!timestamp) return 'Never deployed'; + + const now = new Date(); + const then = new Date(timestamp); + const diffMs = now - then; + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffHours / 24); + + if (diffDays > 0) { + return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + } else if (diffHours > 0) { + return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + } else { + return 'Less than an hour ago'; + } +} + /** * Reads version information from package.json, iOS Info.plist, and Android build.gradle * @returns {Object} Object containing version information for all platforms */ function getCurrentVersions() { + const versionJson = getVersionJsonData(); + return { main: getMainVersion(), ios: getIOSVersion(), android: getAndroidVersion(), + versionJson: versionJson, }; } @@ -309,19 +351,64 @@ function displayPlatformVersions(platform, versions) { console.log(`${CONSOLE_SYMBOLS.PACKAGE} Main Version: ${versions.main}`); if (platform === PLATFORMS.IOS || platform === PLATFORMS.BOTH) { + const currentBuild = versions.ios.build; + const nextBuild = versions.versionJson + ? versions.versionJson.ios.build + 1 + : parseInt(currentBuild) + 1; + const lastDeployed = versions.versionJson + ? getTimeAgo(versions.versionJson.ios.lastDeployed) + : 'Unknown'; + console.log( `${CONSOLE_SYMBOLS.APPLE} iOS Version: ${versions.ios.version}`, ); - console.log(`${CONSOLE_SYMBOLS.APPLE} iOS Build: ${versions.ios.build}`); + console.log( + `${CONSOLE_SYMBOLS.APPLE} iOS Build: ${currentBuild} โ†’ ${nextBuild}`, + ); + console.log(`${CONSOLE_SYMBOLS.APPLE} Last iOS Deploy: ${lastDeployed}`); } if (platform === PLATFORMS.ANDROID || platform === PLATFORMS.BOTH) { + const currentBuild = versions.android.versionCode; + const nextBuild = versions.versionJson + ? versions.versionJson.android.build + 1 + : parseInt(currentBuild) + 1; + const lastDeployed = versions.versionJson + ? getTimeAgo(versions.versionJson.android.lastDeployed) + : 'Unknown'; + console.log( `${CONSOLE_SYMBOLS.ANDROID} Android Version: ${versions.android.version}`, ); console.log( - `${CONSOLE_SYMBOLS.ANDROID} Android Version Code: ${versions.android.versionCode}`, + `${CONSOLE_SYMBOLS.ANDROID} Android Version Code: ${currentBuild} โ†’ ${nextBuild}`, ); + console.log( + `${CONSOLE_SYMBOLS.ANDROID} Last Android Deploy: ${lastDeployed}`, + ); + } + + // Check for potential issues + if (versions.versionJson) { + if (platform === PLATFORMS.IOS || platform === PLATFORMS.BOTH) { + const jsonBuild = versions.versionJson.ios.build; + const actualBuild = parseInt(versions.ios.build); + if (jsonBuild !== actualBuild) { + console.log( + `\n${CONSOLE_SYMBOLS.WARNING} iOS build mismatch: version.json has ${jsonBuild}, but Xcode has ${actualBuild}`, + ); + } + } + + if (platform === PLATFORMS.ANDROID || platform === PLATFORMS.BOTH) { + const jsonBuild = versions.versionJson.android.build; + const actualBuild = parseInt(versions.android.versionCode); + if (jsonBuild !== actualBuild) { + console.log( + `\n${CONSOLE_SYMBOLS.WARNING} Android build mismatch: version.json has ${jsonBuild}, but gradle has ${actualBuild}`, + ); + } + } } } diff --git a/app/scripts/version.cjs b/app/scripts/version.cjs new file mode 100755 index 000000000..7fca51140 --- /dev/null +++ b/app/scripts/version.cjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: BUSL-1.1 + +const fs = require('fs'); +const path = require('path'); + +const VERSION_FILE = path.join(__dirname, '..', 'version.json'); +const PACKAGE_JSON = path.join(__dirname, '..', 'package.json'); + +function readVersionFile() { + try { + const data = fs.readFileSync(VERSION_FILE, 'utf8'); + return JSON.parse(data); + } catch (error) { + console.error('Error reading version.json:', error); + process.exit(1); + } +} + +function writeVersionFile(data) { + try { + fs.writeFileSync(VERSION_FILE, JSON.stringify(data, null, 2) + '\n'); + } catch (error) { + console.error('Error writing version.json:', error); + process.exit(1); + } +} + +function getPackageVersion() { + try { + const packageData = JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf8')); + return packageData.version; + } catch (error) { + console.error('Error reading package.json:', error); + process.exit(1); + } +} + +function bumpBuild(platform = 'both') { + const validPlatforms = ['ios', 'android', 'both']; + if (!validPlatforms.includes(platform)) { + console.error( + `Invalid platform: ${platform}. Must be one of: ${validPlatforms.join(', ')}`, + ); + process.exit(1); + } + + const versionData = readVersionFile(); + + if (platform === 'ios' || platform === 'both') { + versionData.ios.build += 1; + console.log(`โœ… iOS build number bumped to ${versionData.ios.build}`); + } + + if (platform === 'android' || platform === 'both') { + versionData.android.build += 1; + console.log( + `โœ… Android build number bumped to ${versionData.android.build}`, + ); + } + + writeVersionFile(versionData); +} + +function setDeploymentTime(platform) { + const validPlatforms = ['ios', 'android', 'both']; + if (!validPlatforms.includes(platform)) { + console.error( + `Invalid platform: ${platform}. Must be one of: ${validPlatforms.join(', ')}`, + ); + process.exit(1); + } + + const versionData = readVersionFile(); + const timestamp = new Date().toISOString(); + + if (platform === 'ios' || platform === 'both') { + versionData.ios.lastDeployed = timestamp; + } + + if (platform === 'android' || platform === 'both') { + versionData.android.lastDeployed = timestamp; + } + + writeVersionFile(versionData); + console.log(`โœ… Updated ${platform} deployment timestamp`); +} + +function getCurrentInfo() { + const versionData = readVersionFile(); + const version = getPackageVersion(); + + console.log(`Current version: ${version} (from package.json)`); + console.log(`iOS build: ${versionData.ios.build}`); + console.log(`Android build: ${versionData.android.build}`); + + if (versionData.ios.lastDeployed) { + console.log(`iOS last deployed: ${versionData.ios.lastDeployed}`); + } + if (versionData.android.lastDeployed) { + console.log(`Android last deployed: ${versionData.android.lastDeployed}`); + } + + return { version, ...versionData }; +} + +// CLI handling +const command = process.argv[2]; +const arg = process.argv[3]; + +switch (command) { + case 'bump-build': + bumpBuild(arg || 'both'); + break; + case 'deployed': + setDeploymentTime(arg || 'both'); + break; + case 'get': + case 'info': + getCurrentInfo(); + break; + default: + console.log('Usage:'); + console.log( + ' node version.cjs bump-build [ios|android|both] - Bump build number', + ); + console.log( + ' node version.cjs deployed [ios|android|both] - Update deployment timestamp', + ); + console.log( + ' node version.cjs info - Get current version info', + ); + console.log(''); + console.log('Note: Version numbers are managed by npm version command'); + process.exit(1); +} diff --git a/app/version.json b/app/version.json new file mode 100644 index 000000000..79d1e28db --- /dev/null +++ b/app/version.json @@ -0,0 +1,10 @@ +{ + "ios": { + "build": 148, + "lastDeployed": null + }, + "android": { + "build": 82, + "lastDeployed": null + } +}