diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index bef37c1..48a7da1 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -52,7 +52,7 @@ jobs: --arg key "$APP_STORE_CONNECT_API_PRIVATE_KEY" \ '{key_id: $key_id, issuer_id: $issuer_id, key: $key}' > fastlane/api-key.json - - run: bundle exec fastlane release_beta_ci + - run: bundle exec fastlane beta_ci env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 662298e..2e8f7d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,20 +2,53 @@ name: Release to App Store on: workflow_dispatch: + inputs: + ref: + description: "Git ref (branch, tag, or SHA) to release from. Defaults to main." + required: false + default: main + type: string -# Static concurrency group since we always release from main, regardless of trigger branch +# Static concurrency group: only one release may run at a time, regardless of ref, +# to avoid App Store Connect contention. concurrency: - group: ${{ github.workflow }}-main + group: ${{ github.workflow }} cancel-in-progress: true permissions: contents: write jobs: - release: - name: Release to App Store + # ============================================================================ + # Phase 0: Resolve the dispatch ref to an exact SHA once so every downstream + # job checks out the same commit. Prevents divergence if the ref advances + # mid-release. Defaults to main; override via the `ref` dispatch input. + # ============================================================================ + resolve-ref: + name: Resolve Release SHA + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + sha: ${{ steps.resolve.outputs.sha }} + steps: + - name: Resolve ref to SHA + id: resolve + env: + GH_TOKEN: ${{ github.token }} + RELEASE_REF: ${{ inputs.ref }} + run: | + SHA=$(gh api "repos/${{ github.repository }}/commits/$RELEASE_REF" --jq .sha) + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "Releasing from $RELEASE_REF @ $SHA" + + # ============================================================================ + # 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 + needs: resolve-ref steps: - name: Generate GitHub App Token id: github_app_token @@ -27,7 +60,7 @@ jobs: - name: Checkout Code uses: actions/checkout@v6 with: - ref: main + ref: ${{ needs.resolve-ref.outputs.sha }} submodules: true token: ${{ steps.github_app_token.outputs.token }} @@ -51,8 +84,191 @@ 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 publish_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 + needs: resolve-ref + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + ref: ${{ needs.resolve-ref.outputs.sha }} + 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: [resolve-ref, 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: ${{ needs.resolve-ref.outputs.sha }} + 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: [resolve-ref, 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: ${{ needs.resolve-ref.outputs.sha }} + 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 publish_ci_upload version:${{ steps.version.outputs.version }} build:${{ steps.version.outputs.build }} ref:${{ inputs.ref }} env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index a6a2fcb..97f36b0 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, beta_ci, publish, publish_ci_build, publish_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..2dfcbe7 100644 --- a/fastlane/lanes/release.rb +++ b/fastlane/lanes/release.rb @@ -63,7 +63,7 @@ Single CI lane used by release-beta.yml (scheduled/manual). No PR. Creates a signed, verified commit via the GitHub API linked to the GitHub App. DESC -lane :release_beta_ci do +lane :beta_ci do setup_ci if is_ci # Prepare: check App Store Connect, bump patch if needed, get next build from TestFlight @@ -134,12 +134,12 @@ end desc <<~DESC - Release to App Store: build, upload with metadata/screenshots, submit for review - Single CI lane used by release.yml (manual trigger). No PR. - Creates a signed, verified commit via the GitHub API linked to the GitHub App. - Generates screenshots, uploads metadata and binary, and submits for App Store review. + 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 do +lane :publish_ci_build do setup_ci if is_ci # Prepare: check App Store Connect, bump patch if needed, get next build from TestFlight @@ -154,9 +154,36 @@ _validate_app _setup_sentry_release(version: version_number, build: build_number) - # Generate screenshots - UI.message "Generating screenshots for App Store..." - generate_screenshots + # 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) + ref: git ref the release was dispatched from (optional, default "main"). + Commit+tag on main is skipped when ref != "main" to avoid creating + version tags whose tree doesn't match what was uploaded. +DESC +lane :publish_ci_upload do |options| + setup_ci if is_ci + + version_number = options[:version] + build_number = options[:build] + release_ref = options[:ref] || "main" + + 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( @@ -164,7 +191,6 @@ 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, @@ -187,6 +213,13 @@ _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) + # Commit and tag on main via GitHub API (creates a signed, verified commit). + # Skipped for non-main dispatches: _commit_and_tag_version_signed overlays + # VERSION_BUMP_FILES onto main's tree, which would not match the IPA that + # was actually uploaded from a feature branch. + if release_ref == "main" + _commit_and_tag_version_signed(version: version_number, build: build_number) + else + UI.important "Skipping commit+tag: dispatched from ref '#{release_ref}', not main" + end end diff --git a/fastlane/lanes/screenshots.rb b/fastlane/lanes/screenshots.rb new file mode 100644 index 0000000..5a11f9c --- /dev/null +++ b/fastlane/lanes/screenshots.rb @@ -0,0 +1,285 @@ +# 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) + + # Two layers of retry: + # - number_of_retries: xcodebuild reruns failing tests (-test-iterations + -retry-tests-on-failure). + # - outer loop: full simulator reboot if even the in-test retries are exhausted, e.g. a wedged sim. + # output_remove_retry_attempts strips retried-then-passing iterations so scan's exit code reflects + # the final outcome rather than the first transient failure. + max_attempts = 2 + last_failures = 0 + + (1..max_attempts).each do |attempt| + UI.message "Screenshot test attempt #{attempt}/#{max_attempts} on #{device}" + begin + result = run_tests( + project: "Flinky.xcodeproj", + scheme: "ScreenshotUITests", + xctestrun: xctestrun_path, + test_without_building: true, + destination: "platform=iOS Simulator,name=#{device}", + only_testing: ["ScreenshotUITests/ScreenshotUITests/testScreenshots"], + reinstall_app: true, + output_types: "", + number_of_retries: 3, + output_remove_retry_attempts: true, + fail_build: false + ) + last_failures = result[:number_of_failures_excluding_retries] || 0 + ensure + _clear_status_bar(udid: simulator_udid) + end + + break if last_failures.zero? + + if attempt < max_attempts + UI.important "Tests failed with #{last_failures} failure(s) after in-test retries; rebooting simulator and retrying" + _reboot_simulator(udid: simulator_udid) + _override_status_bar(udid: simulator_udid) + end + end + + if last_failures.positive? + UI.user_error!("Screenshot tests on #{device} failed after #{max_attempts} attempts (#{last_failures} failure(s) excluding retries)") + 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 and block until it is fully booted. +# `simctl boot` returns as soon as the boot process is kicked off, not when +# the device is ready. `bootstatus -b` blocks until the simulator reaches the +# home screen. Without this, subsequent `simctl status_bar override` calls +# race the boot and silently no-op, leaking the real clock into screenshots. +private_lane :_boot_simulator do |options| + udid = options[:udid] + UI.message "Booting simulator: #{udid}" + sh("xcrun simctl boot #{udid} 2>/dev/null || true") + sh("xcrun simctl bootstatus #{udid} -b") +end + +# Private lane: Force a clean simulator reboot — used between outer test retries +# to recover from wedged state that survives `-retry-tests-on-failure` (stuck +# springboard, leaked test runner processes, frozen UI). Shutdown is best-effort +# because the device may already be in a bad state. +private_lane :_reboot_simulator do |options| + udid = options[:udid] + UI.message "Rebooting simulator: #{udid}" + sh("xcrun simctl shutdown #{udid} 2>/dev/null || true") + sh("xcrun simctl boot #{udid} 2>/dev/null || true") + sh("xcrun simctl bootstatus #{udid} -b") +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") +end