diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e2252e..206c716 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,10 +12,13 @@ permissions: contents: write jobs: - release: - name: Release to App Store + # ============================================================================ + # Phase 1: Build IPA and screenshot test bundle in parallel + # ============================================================================ + release-build: + name: Build Release runs-on: macos-26 - timeout-minutes: 120 + timeout-minutes: 60 steps: - name: Generate GitHub App Token id: github_app_token @@ -51,8 +54,190 @@ jobs: --arg key "$APP_STORE_CONNECT_API_PRIVATE_KEY" \ '{key_id: $key_id, issuer_id: $issuer_id, key: $key}' > fastlane/api-key.json - - name: Release to App Store - run: bundle exec fastlane release_ci + - name: Build, Validate, and Setup Sentry + run: bundle exec fastlane release_ci_build + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + MATCH_GIT_PRIVATE_KEY: ${{ secrets.MATCH_GIT_PRIVATE_KEY }} + LICENSE_PLIST_GITHUB_TOKEN: ${{ steps.github_app_token.outputs.token }} + RELEASE_BOT_TOKEN: ${{ steps.github_app_token.outputs.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + + - name: Upload Release Artifacts + uses: actions/upload-artifact@v7 + with: + name: release-build + path: | + Flinky.ipa + Flinky.app.dSYM.zip + Flinky.xcarchive + version.txt + build_number.txt + Flinky.xcodeproj/project.pbxproj + Targets/App/Sources/Resources/Settings.bundle/Root.plist + retention-days: 1 + + - name: Run CI Diagnostics + if: failure() + run: ./Scripts/ci-diagnostics.sh + + build-screenshots: + name: Build Screenshots + runs-on: macos-26 + timeout-minutes: 30 + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + ref: main + submodules: true + + - name: Install Dependencies + run: brew bundle --file Brewfile-ci + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Build Screenshot Test Bundle + run: bundle exec fastlane build_screenshots derived_data_path:/tmp/screenshot_build + env: + LICENSE_PLIST_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Test Bundle + uses: actions/upload-artifact@v7 + with: + name: screenshot-test-bundle + path: /tmp/screenshot_build/Build/Products/ + retention-days: 1 + + - name: Run CI Diagnostics + if: failure() + run: ./Scripts/ci-diagnostics.sh + + # ============================================================================ + # Phase 2: Run screenshots on each device in parallel + # ============================================================================ + + screenshot: + name: Screenshot ${{ matrix.device }} + runs-on: macos-26 + timeout-minutes: 30 + needs: build-screenshots + strategy: + fail-fast: false + matrix: + device: + - "iPhone 17 Pro Max" + - "iPhone 17 Pro" + - "iPad Pro 13-inch (M5)" + - "iPad Pro 11-inch (M5)" + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + ref: main + submodules: true + + - name: Install Dependencies + run: brew bundle --file Brewfile-ci + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Download Test Bundle + uses: actions/download-artifact@v7 + with: + name: screenshot-test-bundle + path: /tmp/screenshot_build/Build/Products/ + + - name: Run Screenshots + run: bundle exec fastlane run_screenshot_on_device "device:${{ matrix.device }}" derived_data_path:/tmp/screenshot_build + + - name: Upload Device Screenshots + uses: actions/upload-artifact@v7 + if: always() + with: + name: screenshots-${{ matrix.device }} + path: ~/Library/Caches/tools.fastlane/screenshots/*.png + retention-days: 1 + + - name: Run CI Diagnostics + if: failure() + run: ./Scripts/ci-diagnostics.sh + + # ============================================================================ + # Phase 3: Collect screenshots and upload to App Store + # ============================================================================ + + release-upload: + name: Upload to App Store + runs-on: macos-26 + timeout-minutes: 60 + needs: [release-build, screenshot] + steps: + - name: Generate GitHub App Token + id: github_app_token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.TECHPRIMATE_RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.TECHPRIMATE_RELEASE_BOT_PRIVATE_KEY }} + + - name: Checkout Code + uses: actions/checkout@v6 + with: + ref: main + submodules: true + token: ${{ steps.github_app_token.outputs.token }} + + - name: Install Dependencies + run: brew bundle --file Brewfile-ci + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Create App Store Connect API Key + env: + APP_STORE_CONNECT_API_KEY_ID: ${{ vars.APP_STORE_CONNECT_API_KEY_ID }} + APP_STORE_CONNECT_API_ISSUER_ID: ${{ vars.APP_STORE_CONNECT_API_ISSUER_ID }} + APP_STORE_CONNECT_API_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }} + run: | + jq -n \ + --arg key_id "$APP_STORE_CONNECT_API_KEY_ID" \ + --arg issuer_id "$APP_STORE_CONNECT_API_ISSUER_ID" \ + --arg key "$APP_STORE_CONNECT_API_PRIVATE_KEY" \ + '{key_id: $key_id, issuer_id: $issuer_id, key: $key}' > fastlane/api-key.json + + - name: Download Release Build + uses: actions/download-artifact@v7 + with: + name: release-build + path: . + + - name: Download All Screenshots + uses: actions/download-artifact@v7 + with: + pattern: screenshots-* + path: ~/Library/Caches/tools.fastlane/screenshots/ + merge-multiple: true + + - name: Collect Screenshots + run: bundle exec fastlane collect_screenshots + + - name: Read Version Info + id: version + run: | + echo "version=$(cat version.txt)" >> "$GITHUB_OUTPUT" + echo "build=$(cat build_number.txt)" >> "$GITHUB_OUTPUT" + + - name: Upload to App Store and Submit for Review + run: bundle exec fastlane release_ci_upload version:${{ steps.version.outputs.version }} build:${{ steps.version.outputs.build }} env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index a6a2fcb..8e945d9 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -10,8 +10,9 @@ # ├── Fastfile # This file - imports and platform definition # ├── lanes/ # │ ├── build.rb # build_ci lane -# │ ├── release.rb # beta, release_beta_ci, publish +# │ ├── release.rb # beta, release_beta_ci, publish, release_ci_build, release_ci_upload # │ ├── utilities.rb # generate_*, upload_metadata, setup_code_signing, bump_* +# │ ├── screenshots.rb # Parallel screenshot lanes (build_screenshots, run_screenshot_on_device, etc.) # │ ├── helpers.rb # Private helper lanes (_bump_version, _build_app_for_store, etc.) # │ ├── sentry.rb # Sentry integration lanes # │ ├── version.rb # Version management lanes @@ -38,4 +39,5 @@ platform :ios do import "lanes/build.rb" import "lanes/release.rb" import "lanes/utilities.rb" + import "lanes/screenshots.rb" end diff --git a/fastlane/lanes/release.rb b/fastlane/lanes/release.rb index e3bf887..67fa3ca 100644 --- a/fastlane/lanes/release.rb +++ b/fastlane/lanes/release.rb @@ -190,3 +190,84 @@ # Commit and tag on main via GitHub API (creates a signed, verified commit) _commit_and_tag_version_signed(version: version_number, build: build_number) end + +desc <<~DESC + Release CI: Build phase + Prepares version, builds IPA, validates, and sets up Sentry release. + Outputs version and build number for downstream jobs. + Used by the parallel release workflow (release.yml). +DESC +lane :release_ci_build do + setup_ci if is_ci + + # Prepare: check App Store Connect, bump patch if needed, get next build from TestFlight + version_check_result = _check_and_bump_version_if_needed + version_number = version_check_result[:version] + build_number = _get_next_build_number(version: version_number) + _make(target: "generate") + + # Build and validate + _setup_code_signing + _build_app_for_store + _validate_app + _setup_sentry_release(version: version_number, build: build_number) + + # Write version info for downstream jobs + Dir.chdir("..") do + File.write("version.txt", version_number) + File.write("build_number.txt", build_number) + end + + UI.success "✅ Release build complete: #{version_number} (#{build_number})" +end + +desc <<~DESC + Release CI: Upload phase + Uploads IPA and screenshots to App Store Connect, submits for review, + finalizes Sentry release, and commits/tags the version. + Expects IPA at project root and screenshots in fastlane/screenshots/. + Options: + version: version number (required) + build: build number (required) +DESC +lane :release_ci_upload do |options| + setup_ci if is_ci + + version_number = options[:version] + build_number = options[:build] + + UI.user_error!("version is required") unless version_number + UI.user_error!("build is required") unless build_number + + # Upload to App Store Connect with metadata, screenshots, and submit for review + upload_to_app_store( + api_key_path: File.expand_path("./api-key.json"), + ipa: File.expand_path("../Flinky.ipa"), # Explicit path to avoid relying on SharedValues + + app_version: version_number, + build_number: build_number, + + skip_binary_upload: false, + overwrite_screenshots: true, + submit_for_review: true, + + run_precheck_before_submit: false, + precheck_include_in_app_purchases: false, + + languages: ["en-US"], + metadata_path: File.expand_path("./metadata"), + screenshots_path: File.expand_path("./screenshots"), + + force: true, # Skip the preview HTML + + app_review_information: { + email_address: ENV["APP_REVIEW_EMAIL_ADDRESS"], + phone_number: ENV["APP_REVIEW_PHONE_NUMBER"] + } + ) + + _finalize_sentry_release(version: version_number, build: build_number) + + # Commit and tag on main via GitHub API (creates a signed, verified commit) + _commit_and_tag_version_signed(version: version_number, build: build_number) +end diff --git a/fastlane/lanes/screenshots.rb b/fastlane/lanes/screenshots.rb new file mode 100644 index 0000000..76aeaa8 --- /dev/null +++ b/fastlane/lanes/screenshots.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +require "fileutils" +require "json" + +# ============================================================================ +# SCREENSHOT LANES +# ============================================================================ +# Lanes for parallel screenshot generation using build-for-testing and +# test-without-building. This avoids fastlane snapshot's per-device-family +# rebuild and enables splitting across multiple CI jobs. +# +# Usage: +# 1. build_screenshots → builds test bundle once +# 2. run_screenshot_on_device → runs tests on a single device (parallelizable) +# 3. collect_screenshots → gathers results into fastlane/screenshots/ +# +# CI workflow runs step 1, then step 2 in parallel jobs, then step 3. +# ============================================================================ + +# Screenshots output directory relative to project root +SCREENSHOTS_OUTPUT_DIR = "fastlane/screenshots" + +# Default devices for App Store Connect +SCREENSHOT_DEVICES = [ + "iPhone 17 Pro Max", # iPhone 6.9" display + "iPhone 17 Pro", # iPhone 6.3" display + "iPad Pro 13-inch (M5)", # iPad 13" display + "iPad Pro 11-inch (M5)" # iPad 11" display +].freeze + +# Default language for screenshots +SCREENSHOT_LANGUAGE = "en-US" + +desc <<~DESC + Build screenshot test bundle for testing + Runs xcodebuild build-for-testing to compile the test bundle once. + The output can then be used by run_screenshot_on_device for each device. + Options: + derived_data_path: path for build products (default: /tmp/screenshot_derived_data) +DESC +lane :build_screenshots do |options| + derived_data_path = options[:derived_data_path] || "/tmp/screenshot_derived_data" + + UI.message "Building screenshot test bundle..." + + run_tests( + project: "Flinky.xcodeproj", + scheme: "ScreenshotUITests", + configuration: "Debug", + derived_data_path: derived_data_path, + destination: "generic/platform=iOS Simulator", + build_for_testing: true, + xcargs: "SWIFT_TREAT_WARNINGS_AS_ERRORS=NO" + ) + + # Find the generated xctestrun file + xctestrun_files = Dir.glob("#{derived_data_path}/Build/Products/*.xctestrun") + UI.user_error!("No .xctestrun file found in #{derived_data_path}/Build/Products/") if xctestrun_files.empty? + + xctestrun_path = xctestrun_files.first + UI.success "✅ Screenshot test bundle built successfully!" + UI.message "xctestrun: #{xctestrun_path}" + UI.message "Products: #{derived_data_path}/Build/Products/" + + next xctestrun_path +end + +desc <<~DESC + Run screenshot tests on a single device + Runs test-without-building on the specified device using a pre-built test bundle. + Handles simulator status bar override and screenshot collection. + Options: + device: simulator device name (required, e.g. "iPhone 17 Pro") + derived_data_path: path to pre-built test products (default: /tmp/screenshot_derived_data) + language: language code for screenshots (default: en-US) +DESC +lane :run_screenshot_on_device do |options| + device = options[:device] + UI.user_error!("device is required") unless device + + derived_data_path = options[:derived_data_path] || "/tmp/screenshot_derived_data" + language = options[:language] || SCREENSHOT_LANGUAGE + + # Find xctestrun file + xctestrun_files = Dir.glob("#{derived_data_path}/Build/Products/*.xctestrun") + UI.user_error!("No .xctestrun file found. Run build_screenshots first.") if xctestrun_files.empty? + xctestrun_path = xctestrun_files.first + + # Setup fastlane snapshot cache directory (SnapshotHelper.swift reads from here) + cache_dir = File.expand_path("~/Library/Caches/tools.fastlane") + screenshots_dir = "#{cache_dir}/screenshots" + FileUtils.mkdir_p(screenshots_dir) + + File.write("#{cache_dir}/language.txt", language) + File.write("#{cache_dir}/locale.txt", language) + File.write("#{cache_dir}/snapshot-launch_arguments.txt", "") + + UI.message "Running screenshots on: #{device}" + + # Boot simulator and override status bar + simulator_udid = _find_simulator_udid(device: device) + _boot_simulator(udid: simulator_udid) + _override_status_bar(udid: simulator_udid) + + begin + # Run tests without building using the pre-built xctestrun + # Note: only pass xctestrun + destination, NOT project/scheme, + # otherwise scan ignores xctestrun and tries a full build. + run_tests( + xctestrun: xctestrun_path, + destination: "platform=iOS Simulator,name=#{device}", + only_testing: ["ScreenshotUITests/ScreenshotUITests/testScreenshots"], + reinstall_app: true, + output_types: "", + fail_build: true + ) + ensure + # Always clear status bar, even on failure + _clear_status_bar(udid: simulator_udid) + end + + # Verify screenshots were generated for this device + screenshots_dir = File.expand_path("~/Library/Caches/tools.fastlane/screenshots") + device_screenshots = Dir.glob("#{screenshots_dir}/#{device}-*.png") + if device_screenshots.empty? + UI.user_error!("No screenshots found for #{device} in #{screenshots_dir}") + end + + UI.success "✅ #{device_screenshots.length} screenshots captured on #{device}" + device_screenshots.each { |f| UI.message " #{File.basename(f)}" } +end + +desc <<~DESC + Collect screenshots from cache into fastlane/screenshots directory + Gathers screenshots generated by run_screenshot_on_device into the + fastlane/screenshots/en-US/ directory structure expected by deliver. + Options: + language: language code (default: en-US) + output_dir: output directory (default: fastlane/screenshots) +DESC +lane :collect_screenshots do |options| + language = options[:language] || SCREENSHOT_LANGUAGE + output_dir = options[:output_dir] || SCREENSHOTS_OUTPUT_DIR + + cache_dir = File.expand_path("~/Library/Caches/tools.fastlane/screenshots") + lang_dir = File.expand_path("../#{output_dir}/#{language}") + + # Clear previous screenshots + FileUtils.rm_rf(lang_dir) if Dir.exist?(lang_dir) + FileUtils.mkdir_p(lang_dir) + + screenshots = Dir.glob("#{cache_dir}/*.png") + if screenshots.empty? + UI.user_error!("No screenshots found in #{cache_dir}. Run run_screenshot_on_device first.") + end + + screenshots.each do |file| + FileUtils.cp(file, lang_dir) + UI.message " ✅ #{File.basename(file)}" + end + + expected = SCREENSHOT_DEVICES.length * 4 # 4 screenshots per device + if screenshots.length == expected + UI.success "✅ All #{screenshots.length} screenshots collected!" + else + UI.important "⚠️ Collected #{screenshots.length} screenshots (expected #{expected})" + end +end + +desc <<~DESC + Generate all screenshots using a single build + Builds the test bundle once, then runs tests on all devices sequentially. + For local use — CI uses parallel jobs instead. + Options: + derived_data_path: path for build products (default: /tmp/screenshot_derived_data) +DESC +lane :generate_screenshots_parallel do |options| + derived_data_path = options[:derived_data_path] || "/tmp/screenshot_derived_data" + + # Build once + build_screenshots(derived_data_path: derived_data_path) + + # Clear screenshot cache + cache_dir = File.expand_path("~/Library/Caches/tools.fastlane/screenshots") + FileUtils.rm_rf(cache_dir) + FileUtils.mkdir_p(cache_dir) + + # Run on all devices + SCREENSHOT_DEVICES.each do |device| + run_screenshot_on_device( + device: device, + derived_data_path: derived_data_path + ) + end + + # Collect results + collect_screenshots +end + +# Private lane: Find simulator UDID by device name +private_lane :_find_simulator_udid do |options| + device = options[:device] + + devices_json = sh("xcrun simctl list devices available -j", log: false).strip + devices = JSON.parse(devices_json)["devices"].values.flatten + match = devices.find { |d| d["name"] == device && d["isAvailable"] } + UI.user_error!("Simulator not found: #{device}") unless match + + next match["udid"] +end + +# Private lane: Boot a simulator by UDID +private_lane :_boot_simulator do |options| + udid = options[:udid] + UI.message "Booting simulator: #{udid}" + sh("xcrun simctl boot #{udid} 2>/dev/null || true", log: false) +end + +# Private lane: Override simulator status bar for clean screenshots +private_lane :_override_status_bar do |options| + udid = options[:udid] + UI.message "Overriding status bar..." + sh( + "xcrun", "simctl", "status_bar", udid, "override", + "--time", "09:41", + "--dataNetwork", "wifi", + "--wifiMode", "active", + "--wifiBars", "3", + "--cellularMode", "active", + "--operatorName", "", + "--cellularBars", "4", + "--batteryState", "charged", + "--batteryLevel", "100" + ) +end + +# Private lane: Clear simulator status bar override +private_lane :_clear_status_bar do |options| + udid = options[:udid] + UI.message "Clearing status bar override..." + sh("xcrun simctl status_bar #{udid} clear 2>/dev/null || true", log: false) +end