From ba7ba4ae9510911824856e2e7f6eff5aed07acab Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Fri, 20 Mar 2026 18:22:32 +0100 Subject: [PATCH 1/9] perf(ci): Parallelize screenshot generation across CI jobs Split screenshot generation into build-once + run-per-device jobs to eliminate fastlane snapshot's per-device-family rebuild and enable targeted retries on failure. New lanes: - build_screenshots: xcodebuild build-for-testing (single build) - run_screenshot_on_device: test-without-building on one device - collect_screenshots: gather results into fastlane/screenshots/ - generate_screenshots_parallel: local convenience lane Split release_ci into release_ci_build + release_ci_upload so the release workflow can run IPA build and screenshot generation in parallel, then combine for App Store upload. --- .github/workflows/release.yml | 195 ++++++++++++++++++++++++++- fastlane/Fastfile | 4 +- fastlane/lanes/release.rb | 81 ++++++++++++ fastlane/lanes/screenshots.rb | 243 ++++++++++++++++++++++++++++++++++ 4 files changed, 517 insertions(+), 6 deletions(-) create mode 100644 fastlane/lanes/screenshots.rb 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 From 777e0730e4b2a16ab2f4704e4f3e4a44e958861a Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 21 Apr 2026 15:28:54 +0200 Subject: [PATCH 2/9] ci(release): Pin all release jobs to a single resolved main SHA Adds a resolve-ref job that captures main's HEAD once, then feeds that SHA into every downstream job's checkout. Without this, each parallel job resolves `ref: main` independently and could diverge if main advances mid-release. --- .github/workflows/release.yml | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 206c716..e3b25ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,26 @@ permissions: contents: write jobs: + # ============================================================================ + # Phase 0: Resolve the exact main SHA once so every downstream job checks + # out the same commit. Prevents divergence if main advances mid-release. + # ============================================================================ + resolve-ref: + name: Resolve Release SHA + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + sha: ${{ steps.resolve.outputs.sha }} + steps: + - name: Resolve main SHA + id: resolve + env: + GH_TOKEN: ${{ github.token }} + run: | + SHA=$(gh api "repos/${{ github.repository }}/commits/main" --jq .sha) + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "Releasing from main @ $SHA" + # ============================================================================ # Phase 1: Build IPA and screenshot test bundle in parallel # ============================================================================ @@ -19,6 +39,7 @@ jobs: name: Build Release runs-on: macos-26 timeout-minutes: 60 + needs: resolve-ref steps: - name: Generate GitHub App Token id: github_app_token @@ -30,7 +51,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 }} @@ -86,11 +107,12 @@ jobs: name: Build Screenshots runs-on: macos-26 timeout-minutes: 30 + needs: resolve-ref steps: - name: Checkout Code uses: actions/checkout@v6 with: - ref: main + ref: ${{ needs.resolve-ref.outputs.sha }} submodules: true - name: Install Dependencies @@ -125,7 +147,7 @@ jobs: name: Screenshot ${{ matrix.device }} runs-on: macos-26 timeout-minutes: 30 - needs: build-screenshots + needs: [resolve-ref, build-screenshots] strategy: fail-fast: false matrix: @@ -138,7 +160,7 @@ jobs: - name: Checkout Code uses: actions/checkout@v6 with: - ref: main + ref: ${{ needs.resolve-ref.outputs.sha }} submodules: true - name: Install Dependencies @@ -178,7 +200,7 @@ jobs: name: Upload to App Store runs-on: macos-26 timeout-minutes: 60 - needs: [release-build, screenshot] + needs: [resolve-ref, release-build, screenshot] steps: - name: Generate GitHub App Token id: github_app_token @@ -190,7 +212,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 }} From ddd2ba0ec34a4d3f6f097c6a13655a78a08e7d83 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 21 Apr 2026 17:16:52 +0200 Subject: [PATCH 3/9] ci(release): Allow dispatching release from non-main refs Adds a `ref` workflow_dispatch input (default: main) so the release can be tested from a feature branch before merging. The resolve-ref job resolves whichever ref was dispatched to an explicit SHA. Concurrency group is now ref-agnostic: only one release runs at a time regardless of ref, to avoid App Store Connect contention. --- .github/workflows/release.yml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3b25ad..bb6f556 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,10 +2,17 @@ name: Release to App Store on: workflow_dispatch: - -# Static concurrency group since we always release from main, regardless of trigger branch + inputs: + ref: + description: "Git ref (branch, tag, or SHA) to release from. Defaults to main." + required: false + default: main + type: string + +# 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: @@ -13,8 +20,9 @@ permissions: jobs: # ============================================================================ - # Phase 0: Resolve the exact main SHA once so every downstream job checks - # out the same commit. Prevents divergence if main advances mid-release. + # 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 @@ -23,14 +31,15 @@ jobs: outputs: sha: ${{ steps.resolve.outputs.sha }} steps: - - name: Resolve main SHA + - name: Resolve ref to SHA id: resolve env: GH_TOKEN: ${{ github.token }} + RELEASE_REF: ${{ inputs.ref }} run: | - SHA=$(gh api "repos/${{ github.repository }}/commits/main" --jq .sha) + SHA=$(gh api "repos/${{ github.repository }}/commits/$RELEASE_REF" --jq .sha) echo "sha=$SHA" >> "$GITHUB_OUTPUT" - echo "Releasing from main @ $SHA" + echo "Releasing from $RELEASE_REF @ $SHA" # ============================================================================ # Phase 1: Build IPA and screenshot test bundle in parallel From af2fb75c58d8f06fc6b792271a93b194e71c8ec0 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 21 Apr 2026 18:10:17 +0200 Subject: [PATCH 4/9] fix(fastlane): Specify scheme for run_screenshot_on_device tests scan auto-discovers Flinky.xcodeproj from cwd and bails with "Multiple schemes found". Adds explicit scheme: "ScreenshotUITests" and test_without_building: true to reuse the pre-built xctestrun bundle instead of triggering a rebuild. --- fastlane/lanes/screenshots.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/fastlane/lanes/screenshots.rb b/fastlane/lanes/screenshots.rb index 76aeaa8..ad5225a 100644 --- a/fastlane/lanes/screenshots.rb +++ b/fastlane/lanes/screenshots.rb @@ -104,11 +104,14 @@ _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. + # scan auto-discovers Flinky.xcodeproj from cwd and requires a scheme + # to disambiguate. test_without_building + xctestrun ensures the + # pre-built bundle is reused instead of triggering a rebuild. 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, From 9a12b80d9e6a4b37560ddde79eaed2346ebe657b Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 21 Apr 2026 18:27:56 +0200 Subject: [PATCH 5/9] fix(fastlane): Skip commit+tag on non-main release dispatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _commit_and_tag_version_signed overlays VERSION_BUMP_FILES onto main's tree and fast-forwards main, regardless of which ref was dispatched. When dispatched from a feature branch, the resulting tag points to a hybrid tree that does not match the IPA actually uploaded — a data integrity issue. release_ci_upload now accepts a `ref` option and skips the commit+tag step unless ref == main. The release workflow passes inputs.ref through. Normal main releases are unchanged; feature-branch test dispatches leave git history clean. --- .github/workflows/release.yml | 2 +- fastlane/lanes/release.rb | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb6f556..6def63c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -268,7 +268,7 @@ jobs: 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 }} + run: bundle exec fastlane release_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/lanes/release.rb b/fastlane/lanes/release.rb index 67fa3ca..d464c17 100644 --- a/fastlane/lanes/release.rb +++ b/fastlane/lanes/release.rb @@ -229,12 +229,16 @@ 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 :release_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 @@ -268,6 +272,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 From f610d9d6e90f34c86813e4b3048ba9f0088421fa Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 22 Apr 2026 12:07:06 +0200 Subject: [PATCH 6/9] fix(fastlane): Wait for simulator boot before overriding status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _boot_simulator ran `xcrun simctl boot` and immediately returned, but that command only kicks off the boot process — it doesn't wait for the simulator to reach the home screen. On slow CI runners, _override_status_bar would then race the boot and silently no-op, leaking the real clock into screenshots (same class of bug that PR #120 fixed in the snapshot action's path). Add `xcrun simctl bootstatus -b` after `boot` to block until the simulator is fully booted. This matches what fastlane/snapshot does in simulator_launcher_base.rb:124. --- fastlane/lanes/screenshots.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/fastlane/lanes/screenshots.rb b/fastlane/lanes/screenshots.rb index ad5225a..3851178 100644 --- a/fastlane/lanes/screenshots.rb +++ b/fastlane/lanes/screenshots.rb @@ -213,11 +213,16 @@ next match["udid"] end -# Private lane: Boot a simulator by UDID +# 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", log: false) + sh("xcrun simctl bootstatus #{udid} -b 2>/dev/null", log: false) end # Private lane: Override simulator status bar for clean screenshots From 79979004379086606cddeb8d2af498534deb0a10 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 22 Apr 2026 13:36:36 +0200 Subject: [PATCH 7/9] fix(fastlane): Retry run_screenshot_on_device tests up to 3x The matrix screenshot jobs use scan (run_tests) rather than snapshot (capture_screenshots), so the retries added in #119 don't apply here. iPad matrix jobs are consistently flaking at the long-press / context-menu interaction (ScreenshotUITests:66) and failing the whole release because release-upload needs the matrix. Add number_of_retries: 3 to the run_tests call so scan retries the test before giving up, matching the snapshot lane's behaviour. --- fastlane/lanes/screenshots.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fastlane/lanes/screenshots.rb b/fastlane/lanes/screenshots.rb index 3851178..a8988bc 100644 --- a/fastlane/lanes/screenshots.rb +++ b/fastlane/lanes/screenshots.rb @@ -116,7 +116,10 @@ only_testing: ["ScreenshotUITests/ScreenshotUITests/testScreenshots"], reinstall_app: true, output_types: "", - fail_build: true + fail_build: true, + # Absorb long-press / context-menu flake (notably on iPad). Mirrors + # the retries already configured on capture_screenshots in utilities.rb. + number_of_retries: 3 ) ensure # Always clear status bar, even on failure From a93cb48a7807e4f0a3db989541318e19a7fedf83 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Mon, 27 Apr 2026 11:07:47 +0200 Subject: [PATCH 8/9] fix(fastlane): Drop build_number conflict and rename release lanes upload_to_app_store rejects build_number when ipa: is set; the value is read from the IPA. Drop the duplicate arg so publish_ci_upload stops failing with "You can't use 'build_number' and 'ipa' options in one run." Rename release_beta_ci -> beta_ci, release_ci_build -> publish_ci_build, release_ci_upload -> publish_ci_upload so the local/CI lane pairs share prefixes. Drop the dead release_ci lane (superseded by the parallel build/upload split). --- .github/workflows/release-beta.yml | 2 +- .github/workflows/release.yml | 4 +- fastlane/Fastfile | 2 +- fastlane/lanes/release.rb | 65 ++---------------------------- 4 files changed, 7 insertions(+), 66 deletions(-) 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 7484f88..2e8f7d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,7 +85,7 @@ jobs: '{key_id: $key_id, issuer_id: $issuer_id, key: $key}' > fastlane/api-key.json - name: Build, Validate, and Setup Sentry - run: bundle exec fastlane release_ci_build + run: bundle exec fastlane publish_ci_build env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} @@ -268,7 +268,7 @@ jobs: 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 }} ref:${{ inputs.ref }} + 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 8e945d9..97f36b0 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -10,7 +10,7 @@ # ├── Fastfile # This file - imports and platform definition # ├── lanes/ # │ ├── build.rb # build_ci lane -# │ ├── release.rb # beta, release_beta_ci, publish, release_ci_build, release_ci_upload +# │ ├── 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.) diff --git a/fastlane/lanes/release.rb b/fastlane/lanes/release.rb index d464c17..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 @@ -133,71 +133,13 @@ _commit_and_tag_version(version: version_number, build: build_number) 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. -DESC -lane :release_ci 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) - - # Generate screenshots - UI.message "Generating screenshots for App Store..." - generate_screenshots - - # 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 - 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 +lane :publish_ci_build do setup_ci if is_ci # Prepare: check App Store Connect, bump patch if needed, get next build from TestFlight @@ -233,7 +175,7 @@ Commit+tag on main is skipped when ref != "main" to avoid creating version tags whose tree doesn't match what was uploaded. DESC -lane :release_ci_upload do |options| +lane :publish_ci_upload do |options| setup_ci if is_ci version_number = options[:version] @@ -249,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, From f0ba2f9f3dc073eb25abb11e80c34d0319e5d7ef Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Mon, 27 Apr 2026 11:09:07 +0200 Subject: [PATCH 9/9] fix(fastlane): Outer simulator reboot retry for screenshot flakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_screenshot_on_device already passes number_of_retries: 3 to scan, but GHA simulator runs still failed the step when the first iteration flaked and a later iteration passed: scan's exit logic counted the retried attempt as a real failure. Switch the run_tests call to fail_build: false and decide pass/fail from the returned :number_of_failures_excluding_retries. Add output_remove_retry_attempts: true so reports stay clean. Wrap everything in a 2-attempt outer loop that hard-reboots the simulator (shutdown + boot + bootstatus -b) between attempts to recover from a wedged sim that even -retry-tests-on-failure can't unstick. Drop log: false on the simctl boot/shutdown/bootstatus/clear commands so their output is visible when CI fails — only the bulky `simctl list devices -j` JSON dump stays silenced. --- fastlane/lanes/screenshots.rb | 79 ++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 24 deletions(-) diff --git a/fastlane/lanes/screenshots.rb b/fastlane/lanes/screenshots.rb index a8988bc..5a11f9c 100644 --- a/fastlane/lanes/screenshots.rb +++ b/fastlane/lanes/screenshots.rb @@ -103,27 +103,46 @@ _boot_simulator(udid: simulator_udid) _override_status_bar(udid: simulator_udid) - begin - # scan auto-discovers Flinky.xcodeproj from cwd and requires a scheme - # to disambiguate. test_without_building + xctestrun ensures the - # pre-built bundle is reused instead of triggering a rebuild. - 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: "", - fail_build: true, - # Absorb long-press / context-menu flake (notably on iPad). Mirrors - # the retries already configured on capture_screenshots in utilities.rb. - number_of_retries: 3 - ) - ensure - # Always clear status bar, even on failure - _clear_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 @@ -224,8 +243,20 @@ 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) - sh("xcrun simctl bootstatus #{udid} -b 2>/dev/null", log: false) + 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 @@ -250,5 +281,5 @@ 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) + sh("xcrun simctl status_bar #{udid} clear 2>/dev/null || true") end