From 36449136beaea0ded39bcbabbe13216dc77df150 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Mon, 2 Feb 2026 11:23:37 +0100 Subject: [PATCH] refactor(fastlane): Modularize Fastfile into separate lane files Split the 888-line monolithic Fastfile into a modular structure: - fastlane/Fastfile: Main entry with imports (46 lines) - fastlane/lanes/build.rb: build_ci lane - fastlane/lanes/release.rb: beta, prepare_release, deploy_beta, publish - fastlane/lanes/utilities.rb: generate_*, upload_metadata, setup_code_signing, bump_* - fastlane/lanes/helpers.rb: Private helper lanes - fastlane/lanes/sentry.rb: Sentry integration lanes - fastlane/lanes/version.rb: Version management lanes - fastlane/lanes/git.rb: Git and GitHub lanes - fastlane/actions/enable_pr_auto_merge.rb: Custom action for GitHub auto-merge Additional changes: - Add octokit gem for GitHub API interactions - Rename publish-beta-build.yml to deploy-beta.yml - Add prepare-release.yml workflow for scheduled releases - Implement idempotent deploy_beta lane (skips if build already uploaded) - Query TestFlight for next build number in prepare_release --- ...publish-beta-build.yml => deploy-beta.yml} | 40 +- .github/workflows/prepare-release.yml | 67 ++ Gemfile | 1 + Gemfile.lock | 7 + fastlane/Fastfile | 793 +----------------- fastlane/actions/enable_pr_auto_merge.rb | 129 +++ fastlane/lanes/build.rb | 43 + fastlane/lanes/git.rb | 115 +++ fastlane/lanes/helpers.rb | 128 +++ fastlane/lanes/release.rb | 161 ++++ fastlane/lanes/sentry.rb | 71 ++ fastlane/lanes/utilities.rb | 195 +++++ fastlane/lanes/version.rb | 151 ++++ 13 files changed, 1125 insertions(+), 776 deletions(-) rename .github/workflows/{publish-beta-build.yml => deploy-beta.yml} (75%) create mode 100644 .github/workflows/prepare-release.yml create mode 100644 fastlane/actions/enable_pr_auto_merge.rb create mode 100644 fastlane/lanes/build.rb create mode 100644 fastlane/lanes/git.rb create mode 100644 fastlane/lanes/helpers.rb create mode 100644 fastlane/lanes/release.rb create mode 100644 fastlane/lanes/sentry.rb create mode 100644 fastlane/lanes/utilities.rb create mode 100644 fastlane/lanes/version.rb diff --git a/.github/workflows/publish-beta-build.yml b/.github/workflows/deploy-beta.yml similarity index 75% rename from .github/workflows/publish-beta-build.yml rename to .github/workflows/deploy-beta.yml index 9a2e62c..cbfabb0 100644 --- a/.github/workflows/publish-beta-build.yml +++ b/.github/workflows/deploy-beta.yml @@ -1,34 +1,40 @@ -name: Publish Beta Build +name: Deploy Beta on: - schedule: - - cron: "0 4 * * 1" # Every Monday at 04:00 UTC - workflow_dispatch: + push: + branches: + - "release/**" concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.job.name}} cancel-in-progress: true permissions: - contents: write - pull-requests: write + contents: read jobs: - publish: - name: Publish Beta Build + deploy: + name: Deploy Beta Build runs-on: macos-26 timeout-minutes: 60 steps: + - name: Generate GitHub App Token + id: github_app_token + uses: actions/create-github-app-token@v2 + 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: submodules: true - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.github_app_token.outputs.token }} - name: Configure Git run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "techprimate-release-bot" + git config user.email "techprimate-release-bot@users.noreply.github.com" - name: Install Dependencies run: brew bundle --file Brewfile-ci @@ -50,20 +56,12 @@ jobs: --arg key "$APP_STORE_CONNECT_API_PRIVATE_KEY" \ '{key_id: $key_id, issuer_id: $issuer_id, key: $key}' > fastlane/api-key.json - - name: Generate GitHub App Token - id: github_app_token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ vars.TECHPRIMATE_RELEASE_BOT_APP_ID }} - private-key: ${{ secrets.TECHPRIMATE_RELEASE_BOT_PRIVATE_KEY }} - - - run: bundle exec fastlane beta_ci + - run: bundle exec fastlane deploy_beta 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: ${{ secrets.GITHUB_TOKEN }} - GITHUB_APP_TOKEN: ${{ steps.github_app_token.outputs.token }} + LICENSE_PLIST_GITHUB_TOKEN: ${{ steps.github_app_token.outputs.token }} - name: Run CI Diagnostics if: failure() diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..6c691b8 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,67 @@ +name: Prepare Release + +on: + schedule: + - cron: "0 4 * * 1" # Every Monday at 04:00 UTC + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.job.name}} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + +jobs: + prepare: + name: Prepare Release + runs-on: macos-26 + timeout-minutes: 15 + steps: + - name: Generate GitHub App Token + id: github_app_token + uses: actions/create-github-app-token@v2 + 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: + submodules: true + token: ${{ steps.github_app_token.outputs.token }} + + - name: Configure Git + run: | + git config user.name "techprimate-release-bot" + git config user.email "techprimate-release-bot@users.noreply.github.com" + + - 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 + + - run: bundle exec fastlane prepare_release + env: + LICENSE_PLIST_GITHUB_TOKEN: ${{ steps.github_app_token.outputs.token }} + GITHUB_APP_TOKEN: ${{ steps.github_app_token.outputs.token }} + + - name: Run CI Diagnostics + if: failure() + run: ./Scripts/ci-diagnostics.sh diff --git a/Gemfile b/Gemfile index 3b5b643..30493ad 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,7 @@ gem "ostruct" # Required by Ruby v3.5.X gem "benchmark" # Required by Ruby v3.5.X gem "fastlane" +gem "octokit" # GitHub API client for REST and GraphQL operations plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock index 88da10a..465314b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -180,6 +180,9 @@ GEM nanaimo (0.4.0) naturally (2.3.0) nkf (0.2.0) + octokit (10.0.0) + faraday (>= 1, < 3) + sawyer (~> 0.9) optparse (0.8.1) os (1.1.4) ostruct (0.6.3) @@ -195,6 +198,9 @@ GEM rouge (3.28.0) ruby2_keywords (0.0.5) rubyzip (2.4.1) + sawyer (0.9.3) + addressable (>= 2.3.5) + faraday (>= 0.17.3, < 3) security (0.1.5) signet (0.21.0) addressable (~> 2.8) @@ -238,6 +244,7 @@ DEPENDENCIES fastlane fastlane-plugin-appicon fastlane-plugin-sentry (= 2.0.0.pre.rc.2) + octokit ostruct BUNDLED WITH diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 2aafa8b..4ebe514 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,762 +1,45 @@ +# frozen_string_literal: true + +# ============================================================================ +# FLINKY FASTFILE +# ============================================================================ +# Main Fastfile that imports modular lane definitions. +# +# Structure: +# fastlane/ +# ├── Fastfile # This file - imports and platform definition +# ├── lanes/ +# │ ├── build.rb # build_ci lane +# │ ├── release.rb # beta, prepare_release, deploy_beta, publish +# │ ├── utilities.rb # generate_*, upload_metadata, setup_code_signing, bump_* +# │ ├── helpers.rb # Private helper lanes (_bump_version, _build_app_for_store, etc.) +# │ ├── sentry.rb # Sentry integration lanes +# │ ├── version.rb # Version management lanes +# │ └── git.rb # Git and GitHub lanes +# └── actions/ +# └── enable_pr_auto_merge.rb # Custom action for GitHub auto-merge +# +# Run `fastlane lanes` to see available public lanes. +# ============================================================================ + +# Third-party dependencies +fastlane_require 'octokit' + default_platform(:ios) platform :ios do - lane :build_ci do - # Configure CI keychain and set match to readonly to avoid prompts - setup_ci if is_ci - - # Setup code signing - _setup_code_signing - - # Build and export the app to an archive - build_app( - project: "Flinky.xcodeproj", - scheme: "App", - archive_path: "./Flinky.xcarchive", - build_path: ".", - export_options: { - "destination" => "export", - "method" => "app-store-connect", - "provisioningProfiles" => { - "com.techprimate.Flinky" => "match AppStore com.techprimate.Flinky", - "com.techprimate.Flinky.ShareExtension" => "match AppStore com.techprimate.Flinky.ShareExtension" - }, - "signingCertificate" => "Apple Distribution", - "signingStyle" => "manual", - "teamID" => "BZ362SQ6AB", - } - ) - - # Upload the archive to Sentry for further analysis - sentry_upload_build( - auth_token: ENV["SENTRY_AUTH_TOKEN"], - org_slug: "techprimate", - project_slug: "flinky", - xcarchive_path: "./Flinky.xcarchive", - ) if is_ci - end - - desc "Generate app icon sizes from source image using fastlane-plugin-appicon" - lane :generate_app_icons do - UI.message "Generating app icon sizes using fastlane-plugin-appicon" - - # Configure MiniMagick to use ImageMagick for consistent metadata handling - require 'mini_magick' - MiniMagick.configure { |config| config.cli = :imagemagick } - - # Use absolute paths to avoid plugin directory changes - project_root = File.expand_path("..") - source_image = File.join(project_root, "Resources", "AppIcon.png") - assets_path = File.join(project_root, "Targets", "App", "Sources", "Resources", "Assets.xcassets") - icon_output_dir = File.join(assets_path, "AppIcon.appiconset") - - UI.message "Using source image: #{File.basename(source_image)}" - - # Verify source image exists - unless File.exist?(source_image) - UI.user_error! "Source image not found at #{source_image}" - end - - # Generate iOS app icons (iPhone, iPad, App Store) - appicon( - appicon_image_file: source_image, - appicon_devices: [:iphone, :ipad, :ios_marketing], - appicon_path: assets_path, - remove_alpha: true, - minimagick_cli: "imagemagick" - ) - - UI.message "Stripping metadata from generated icons to ensure git-reproducible output" - - # Strip metadata from all generated icon files - Dir.glob(File.join(icon_output_dir, "*.png")).each do |icon_path| - UI.verbose "Processing: #{File.basename(icon_path)}" - - begin - image = MiniMagick::Image.open(icon_path) - image.combine_options do |b| - # Strip all existing metadata - b << "-strip" - - # Set consistent comment - b << "-set" << "comment" << "Generated using fastlane-plugin-appicon" - - # Exclude PNG time chunks to prevent timestamp variations - b << "-define" << "png:exclude-chunks=tIME" - - # Set consistent timestamps to prevent git detecting changes - timestamp = "2024-01-01T00:00:00Z" - b << "-set" << "date:create" << timestamp - b << "-set" << "date:modify" << timestamp - b << "-set" << "date:timestamp" << timestamp - - # Set consistent resolution info - b.density "72x72" - b << "-set" << "units" << "PixelsPerInch" - - # Set consistent user time - b << "-set" << "user:time" << "0" - end - - # Write the processed image back - image.write(icon_path) - - rescue => e - UI.error "Failed to process #{icon_path}: #{e.message}" - end - end - - UI.success "✅ All app icon sizes generated and metadata stripped successfully!" - UI.message "Icons generated in: Targets/App/Sources/Resources/Assets.xcassets/AppIcon.appiconset/" - UI.message "📝 Icons are now git-reproducible (no metadata changes on regeneration)" - end - - desc "Generate screenshots" - lane :generate_screenshots do - UI.message "Generating screenshots" - - capture_screenshots( - scheme: "ScreenshotUITests", - 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 - ], - languages: ["en-US"], - - clear_previous_screenshots: true, - concurrent_simulators: true, - skip_open_summary: true, - - reinstall_app: true, - override_status_bar: true, - localize_simulator: true, - disable_slide_to_type: true, - number_of_retries: 0 - ) - - UI.success "✅ Screenshots generated successfully!" - UI.message "Screenshots generated in: ScreenshotUITests/Screenshots/" - end - - desc "Push a new beta build to TestFlight" - lane :beta do |options| - # Configure CI keychain and set match to readonly to avoid prompts - setup_ci if is_ci - - version_info = _increment_version_and_build - version_number = version_info[:version] - build_number = version_info[:build] - - _setup_code_signing - _build_app_for_store - _validate_app - _setup_sentry_release(version: version_number, build: build_number) - - # Upload to TestFlight - upload_to_testflight( - # API Key file must be located at fastlane/api-key.json - api_key_path: File.expand_path("./api-key.json"), - app_version: version_number, - build_number: build_number, - - distribute_external: options[:distribute_external] || false, - groups: ["Friends At Sentry", "Friends Of Phil"], - beta_app_description: "Thanks for testing Flinky! - -Please focus on testing these core features: -• Creating and editing links - try various URL types (websites, deep links, etc.) -• Organizing links into lists with custom colors and symbols -• Link sharing via QR codes, AirDrop, and standard sharing -• Search functionality across your links and lists -• Link management actions (edit, delete, pin, move between lists) - -Pay special attention to: -• App performance and responsiveness -• Any crashes or unexpected behavior -• UI/UX issues or confusing interactions -• Accessibility with VoiceOver if possible - -In case you notice any issues, please use the \"Send Feedback\" button in the app, or take a screenshot, tap on the share button and select \"Share Beta Feedback\". - -Your feedback helps us improve Flinky for everyone. Thank you!" - ) - - _finalize_sentry_release(version: version_number, build: build_number) - _commit_and_tag_version(version: version_number, build: build_number) - end - - desc "Push a new beta build to TestFlight (CI version - creates PR instead of direct commit)" - lane :beta_ci do |options| - # Configure CI keychain and set match to readonly to avoid prompts - setup_ci if is_ci - - # Check App Store Connect and bump version if needed - version_check_result = _check_and_bump_version_if_needed - version_number = version_check_result[:version] - - # Increment build number - increment_build_number(xcodeproj: "Flinky.xcodeproj") - build_number = get_build_number(xcodeproj: "Flinky.xcodeproj") - - _setup_code_signing - _build_app_for_store - _validate_app - _setup_sentry_release(version: version_number, build: build_number) - - # Upload to TestFlight (internal build only, no external distribution) - upload_to_testflight( - # API Key file must be located at fastlane/api-key.json - api_key_path: File.expand_path("./api-key.json"), - app_version: version_number, - build_number: build_number, - - distribute_external: false, - skip_waiting_for_build_processing: false - ) - - _finalize_sentry_release(version: version_number, build: build_number) - - # Create PR instead of committing directly - _create_version_pr(version: version_number, build: build_number) - end - - desc "Publish a new build to the App Store and submit for review" - lane :publish do - version_info = _increment_version_and_build - version_number = version_info[:version] - build_number = version_info[:build] - - _build_app_for_store - _validate_app - _setup_sentry_release(version: version_number, build: build_number) - - # Upload metadata and binary to App Store Connect, then submit for review - upload_to_app_store( - api_key_path: File.expand_path("./api-key.json"), - - 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_version(version: version_number, build: build_number) - end - - desc "Upload metadata to App Store Connect" - lane :upload_metadata do - UI.message "Uploading metadata to App Store Connect" - upload_to_app_store( - api_key_path: File.expand_path("./api-key.json"), - - skip_binary_upload: true, - overwrite_screenshots: 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"] - } - ) - end - - lane :setup_code_signing do - UI.message "Syncing code signing..." - sync_code_signing( - type: "development", - app_identifier: "com.techprimate.Flinky" - ) - sync_code_signing( - type: "appstore", - app_identifier: "com.techprimate.Flinky" - ) - sync_code_signing( - type: "development", - app_identifier: "com.techprimate.Flinky.ShareExtension" - ) - sync_code_signing( - type: "appstore", - app_identifier: "com.techprimate.Flinky.ShareExtension" - ) - UI.success "✅ Code signing synced successfully!" - end - - desc "Bump the major version number (e.g., 1.1.2 -> 2.0.0)" - lane :bump_version_major do - _bump_version(bump_type: "major") - _make(target: "generate") - end - - desc "Bump the minor version number (e.g., 1.1.2 -> 1.2.0)" - lane :bump_version_minor do - _bump_version(bump_type: "minor") - _make(target: "generate") - end - - desc "Bump the patch version number (e.g., 1.1.2 -> 1.1.3)" - lane :bump_version_patch do - _bump_version(bump_type: "patch") - _make(target: "generate") - end - - # ============================================================================ - # PRIVATE LANES # ============================================================================ - # These lanes are internal helpers and are not exposed in `fastlane lanes` + # IMPORTS - Private lanes (must be imported first as they are used by public lanes) # ============================================================================ + import "lanes/helpers.rb" + import "lanes/sentry.rb" + import "lanes/version.rb" + import "lanes/git.rb" - # Private lane: Bump version number in project.pbxproj - private_lane :_bump_version do |options| - bump_type = options[:bump_type] # "major", "minor", or "patch" - - # Get current version - old_version = get_version_number( - xcodeproj: "Flinky.xcodeproj", - target: "Flinky" - ) - - # Parse version into components - version_parts = old_version.split(".").map(&:to_i) - major = version_parts[0] || 0 - minor = version_parts[1] || 0 - patch = version_parts[2] || 0 - - # Increment the appropriate part - case bump_type - when "major" - major += 1 - minor = 0 - patch = 0 - when "minor" - minor += 1 - patch = 0 - when "patch" - patch += 1 - else - UI.user_error!("Invalid bump_type: #{bump_type}. Must be 'major', 'minor', or 'patch'") - end - - new_version = "#{major}.#{minor}.#{patch}" - - # Update all MARKETING_VERSION entries in project.pbxproj - project_file = File.expand_path("../Flinky.xcodeproj/project.pbxproj") - project_content = File.read(project_file) - - # Replace all MARKETING_VERSION entries - updated_content = project_content.gsub( - /MARKETING_VERSION = #{Regexp.escape(old_version)};/, - "MARKETING_VERSION = #{new_version};" - ) - - # Write the updated content back - File.write(project_file, updated_content) - - UI.success "✅ Version bumped from #{old_version} to #{new_version}" - end - - # Private lane: Setup code signing for App Store Connect - private_lane :_setup_code_signing do - sync_code_signing( - type: "appstore", - readonly: true, - app_identifier: "com.techprimate.Flinky", - git_private_key: ENV["MATCH_GIT_PRIVATE_KEY"] - ) - sync_code_signing( - type: "appstore", - readonly: true, - app_identifier: "com.techprimate.Flinky.ShareExtension", - git_private_key: ENV["MATCH_GIT_PRIVATE_KEY"] - ) - end - - # Private lane: Check App Store Connect and bump patch version if current version is already published - private_lane :_check_and_bump_version_if_needed do - current_version = get_version_number( - xcodeproj: "Flinky.xcodeproj", - target: "Flinky" - ) - - UI.message "Checking App Store Connect for published version..." - - begin - # Get the published version from App Store Connect - # This action sets lane_context[SharedValues::LATEST_VERSION] with the version number - app_store_build_number( - api_key_path: File.expand_path("./api-key.json"), - live: true - ) - - # Get the published version from lane context - published_version = lane_context[SharedValues::LATEST_VERSION] - - unless published_version - raise "Could not retrieve published version from App Store Connect" - end - - UI.message "Published version on App Store Connect: #{published_version}" - - # Compare versions semantically - if _version_already_published?(current_version, published_version) - UI.important "Version #{current_version} is already published. Bumping patch version..." - _bump_version(bump_type: "patch") - _make(target: "generate") - - # Get the new version after bumping - new_version = get_version_number( - xcodeproj: "Flinky.xcodeproj", - target: "Flinky" - ) - UI.success "✅ Version bumped from #{current_version} to #{new_version}" - next { version: new_version, bumped: true } - else - UI.success "✅ Version #{current_version} is not yet published, using current version" - next { version: current_version, bumped: false } - end - rescue => e - UI.important "⚠️ Failed to check App Store Connect: #{e.message}" - UI.important "Falling back to current version without bumping" - next { version: current_version, bumped: false } - end - end - - # Private lane: Compare versions to check if current version is already published - private_lane :_version_already_published? do |current_version, published_version| - # Parse version strings into arrays of integers - current_parts = current_version.split(".").map(&:to_i) - published_parts = published_version.split(".").map(&:to_i) - - # Compare major, minor, patch - (0..2).each do |i| - current_part = current_parts[i] || 0 - published_part = published_parts[i] || 0 - - if current_part < published_part - next false # Current version is lower, not published - elsif current_part > published_part - next false # Current version is higher, not published yet - end - end - - # Versions are equal, so current version is already published - next true - end - - # Private lane: Increment version and build number, return both values - private_lane :_increment_version_and_build do - version_number = get_version_number( - xcodeproj: "Flinky.xcodeproj", - target: "Flinky" - ) - - increment_build_number(xcodeproj: "Flinky.xcodeproj") - build_number = get_build_number(xcodeproj: "Flinky.xcodeproj") - - next { version: version_number, build: build_number } - end - - # Private lane: Build the app for App Store distribution - private_lane :_build_app_for_store do - build_app( - project: "Flinky.xcodeproj", - scheme: "App", - - archive_path: "./Flinky.xcarchive", - build_path: ".", - export_options: { - "destination" => "export", - "method" => "app-store-connect", - "provisioningProfiles" => { - "com.techprimate.Flinky" => "match AppStore com.techprimate.Flinky", - "com.techprimate.Flinky.ShareExtension" => "match AppStore com.techprimate.Flinky.ShareExtension" - }, - "signingCertificate" => "Apple Distribution", - "signingStyle" => "manual", - "teamID" => "BZ362SQ6AB", - } - ) - end - - # Private lane: Validate the app before upload - private_lane :_validate_app do - deliver( - # API Key file must be located at fastlane/api-key.json - api_key_path: File.expand_path("./api-key.json"), - verify_only: true - ) - end - - # Private lane: Setup Sentry release (create release, upload symbols, upload build, set commits) - private_lane :_setup_sentry_release do |options| - version_number = options[:version] - build_number = options[:build] - - # Create Sentry release using the same format as the app - sentry_create_release( - auth_token: ENV["SENTRY_AUTH_TOKEN"], - org_slug: "techprimate", - project_slug: "flinky", - - app_identifier: "com.techprimate.Flinky", - version: version_number - ) - - # Upload debug symbols to Sentry - sentry_debug_files_upload( - auth_token: ENV["SENTRY_AUTH_TOKEN"], - org_slug: "techprimate", - project_slug: "flinky", - - # Wait for the server to fully process uploaded files. Errors - # can only be displayed if --wait is specified, but this will - # significantly slow down the upload process - wait: true - ) - - # Upload the xcarchive to Sentry for further analysis - sentry_upload_build( - auth_token: ENV["SENTRY_AUTH_TOKEN"], - org_slug: "techprimate", - project_slug: "flinky", - xcarchive_path: "./Flinky.xcarchive", - ) - - # Associate commits with the release - sentry_set_commits( - auth_token: ENV["SENTRY_AUTH_TOKEN"], - org_slug: "techprimate", - project_slug: "flinky", - - app_identifier: "com.techprimate.Flinky", - version: version_number, - build: build_number, - auto: true - ) - end - - # Private lane: Finalize Sentry release after successful upload - private_lane :_finalize_sentry_release do |options| - version_number = options[:version] - build_number = options[:build] - - sentry_finalize_release( - auth_token: ENV["SENTRY_AUTH_TOKEN"], - org_slug: "techprimate", - project_slug: "flinky", - - app_identifier: "com.techprimate.Flinky", - version: version_number, - build: build_number - ) - end - - # Private lane: Create PR for version bump (for CI use with protected branches) - private_lane :_create_version_pr do |options| - version_number = options[:version] - build_number = options[:build] - - # Ensure we have GitHub token - github_token = ENV["GITHUB_TOKEN"] || ENV["GITHUB_APP_TOKEN"] - unless github_token - UI.user_error!("GITHUB_TOKEN or GITHUB_APP_TOKEN environment variable is required") - end - - # Create release branch name - branch_name = "release/v#{version_number}+#{build_number}" - - UI.message "Creating release branch: #{branch_name}" - - # Ensure we're on main and up to date - sh("git", "checkout", "main") - sh("git", "pull", "origin", "main") - - # Create and checkout new branch - sh("git", "checkout", "-b", branch_name) - - # Stage version changes (project.pbxproj and Root.plist are updated by build scripts) - git_add(path: ["Flinky.xcodeproj/project.pbxproj", "Targets/App/Sources/Resources/Settings.bundle/Root.plist"]) - - # Create commit with structured message - commit_message = <<~MSG - chore: Bump version to #{version_number} (#{build_number}) - - action=bump,version=#{version_number},build=#{build_number} - MSG - - git_commit( - path: ["Flinky.xcodeproj/project.pbxproj", "Targets/App/Sources/Resources/Settings.bundle/Root.plist"], - message: commit_message.strip - ) - - # Push branch to remote - sh("git", "push", "-u", "origin", branch_name) - - # Create PR - UI.message "Creating pull request..." - - pr_body = <<~BODY - ## Version Bump - - - **Version**: #{version_number} - - **Build**: #{build_number} - - **Tag**: `v#{version_number}+#{build_number}` (will be created after merge) - - This PR bumps the version and build number for the beta release. - - The build has already been uploaded to TestFlight and is ready for testing. - - This PR will be automatically merged once all checks pass. - BODY - - pr_result = create_pull_request( - api_token: github_token, - repo: "techprimate/Flinky", - title: "chore: Bump version to #{version_number} (#{build_number})", - body: pr_body.strip, - head: branch_name, - base: "main" - ) - - pr_url = pr_result[:html_url] - pr_number = pr_result[:number] - - UI.success "✅ Pull request created: #{pr_url}" - - # Enable auto-merge using GitHub GraphQL API - UI.message "Enabling auto-merge for PR ##{pr_number}..." - - begin - require 'net/http' - require 'json' - require 'uri' - - # First, get the PR node_id using REST API - pr_details_uri = URI("https://api.github.com/repos/techprimate/Flinky/pulls/#{pr_number}") - pr_details_http = Net::HTTP.new(pr_details_uri.host, pr_details_uri.port) - pr_details_http.use_ssl = true - - pr_details_request = Net::HTTP::Get.new(pr_details_uri) - pr_details_request["Authorization"] = "Bearer #{github_token}" - pr_details_request["Accept"] = "application/vnd.github+json" - - pr_details_response = pr_details_http.request(pr_details_request) - - unless pr_details_response.code.to_i >= 200 && pr_details_response.code.to_i < 300 - raise "Failed to fetch PR details: #{pr_details_response.body}" - end - - pr_data = JSON.parse(pr_details_response.body) - node_id = pr_data["node_id"] - - unless node_id - raise "Could not find node_id in PR response" - end - - # Use GraphQL API to enable auto-merge - graphql_uri = URI("https://api.github.com/graphql") - graphql_http = Net::HTTP.new(graphql_uri.host, graphql_uri.port) - graphql_http.use_ssl = true - - query = <<~GRAPHQL - mutation { - enablePullRequestAutoMerge(input: { - pullRequestId: "#{node_id}" - mergeMethod: SQUASH - }) { - pullRequest { - autoMergeRequest { - enabledAt - } - } - } - } - GRAPHQL - - graphql_request = Net::HTTP::Post.new(graphql_uri) - graphql_request["Authorization"] = "Bearer #{github_token}" - graphql_request["Content-Type"] = "application/json" - graphql_request.body = { query: query }.to_json - - graphql_response = graphql_http.request(graphql_request) - - if graphql_response.code.to_i >= 200 && graphql_response.code.to_i < 300 - result = JSON.parse(graphql_response.body) - if result["errors"] - UI.important "⚠️ Could not enable auto-merge: #{result['errors'].map { |e| e['message'] }.join(', ')}" - UI.important "You may need to enable it manually in the PR: #{pr_url}" - else - UI.success "✅ Auto-merge enabled for PR ##{pr_number}" - end - else - UI.important "⚠️ Could not enable auto-merge automatically: #{graphql_response.body}" - UI.important "You may need to enable it manually in the PR: #{pr_url}" - end - rescue => e - UI.important "⚠️ Failed to enable auto-merge: #{e.message}" - UI.important "You may need to enable it manually in the PR: #{pr_url}" - end - - next { pr_url: pr_url, pr_number: pr_number, branch: branch_name } - end - - # Private lane: Commit version changes and create git tag - private_lane :_commit_and_tag_version do |options| - version_number = options[:version] - build_number = options[:build] - - # Create version tag - version_tag = "v#{version_number}+#{build_number}" - - # Commit the version changes (project.pbxproj and Root.plist are updated by build scripts) - git_add(path: ["Flinky.xcodeproj/project.pbxproj", "Targets/App/Sources/Resources/Settings.bundle/Root.plist"]) - git_commit( - path: ["Flinky.xcodeproj/project.pbxproj", "Targets/App/Sources/Resources/Settings.bundle/Root.plist"], - message: "chore: Bump version to #{version_number} (#{build_number})" - ) - - # Create git tag for this version - add_git_tag(tag: version_tag) - - # Push the commit and tag to remote - push_to_git_remote( - remote: 'origin', - tags: true - ) - end - - # Private lane: Run a make target - private_lane :_make do |options| - UI.message "Running make target #{options[:target]}" - target = options[:target] - UI.user_error!("target is required") unless target - Dir.chdir("..") do - sh("make", target) - end - UI.success "✅ Make target #{target} run successfully!" - end + # ============================================================================ + # IMPORTS - Public lanes + # ============================================================================ + import "lanes/build.rb" + import "lanes/release.rb" + import "lanes/utilities.rb" end diff --git a/fastlane/actions/enable_pr_auto_merge.rb b/fastlane/actions/enable_pr_auto_merge.rb new file mode 100644 index 0000000..625a7a0 --- /dev/null +++ b/fastlane/actions/enable_pr_auto_merge.rb @@ -0,0 +1,129 @@ +module Fastlane + module Actions + class EnablePrAutoMergeAction < Action + def self.run(params) + require 'octokit' + + pr_number = params[:pr_number] + pr_url = params[:pr_url] + repo = params[:repo] + merge_method = params[:merge_method] + + UI.message("Enabling auto-merge for PR ##{pr_number}...") + + begin + github_token = ENV["GITHUB_TOKEN"] || ENV["GITHUB_APP_TOKEN"] + unless github_token + UI.user_error!("GITHUB_TOKEN or GITHUB_APP_TOKEN environment variable is required") + end + + client = Octokit::Client.new(access_token: github_token) + + # Get PR node_id using REST API + pr = client.pull_request(repo, pr_number) + node_id = pr.node_id + + unless node_id + raise "Could not find node_id for PR ##{pr_number}" + end + + # Enable auto-merge using GraphQL mutation + mutation = <<~GRAPHQL + mutation { + enablePullRequestAutoMerge(input: { + pullRequestId: "#{node_id}" + mergeMethod: #{merge_method} + }) { + pullRequest { + autoMergeRequest { + enabledAt + } + } + } + } + GRAPHQL + + # Use Octokit's post method for GraphQL queries + response = client.post('/graphql', { query: mutation }) + + # Check for GraphQL errors + if response[:errors] && response[:errors].any? + error_messages = response[:errors].map { |e| e[:message] }.join(", ") + UI.important("⚠️ Could not enable auto-merge: #{error_messages}") + UI.important("You may need to enable it manually in the PR: #{pr_url}") + return false + end + + UI.success("✅ Auto-merge enabled for PR ##{pr_number}") + true + rescue Octokit::Error => e + UI.important("⚠️ GitHub API error enabling auto-merge: #{e.message}") + UI.important("You may need to enable it manually in the PR: #{pr_url}") + false + rescue => e + UI.important("⚠️ Failed to enable auto-merge: #{e.message}") + UI.important("You may need to enable it manually in the PR: #{pr_url}") + false + end + end + + def self.description + "Enable auto-merge for a GitHub pull request" + end + + def self.details + "Uses the GitHub GraphQL API to enable auto-merge on a pull request. " \ + "Requires GITHUB_TOKEN or GITHUB_APP_TOKEN environment variable with appropriate permissions." + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :pr_number, + env_name: "ENABLE_PR_AUTO_MERGE_PR_NUMBER", + description: "The pull request number", + type: Integer, + optional: false + ), + FastlaneCore::ConfigItem.new( + key: :pr_url, + env_name: "ENABLE_PR_AUTO_MERGE_PR_URL", + description: "The pull request URL (for error messages)", + type: String, + optional: false + ), + FastlaneCore::ConfigItem.new( + key: :repo, + env_name: "ENABLE_PR_AUTO_MERGE_REPO", + description: "The repository in owner/repo format", + type: String, + default_value: "techprimate/Flinky" + ), + FastlaneCore::ConfigItem.new( + key: :merge_method, + env_name: "ENABLE_PR_AUTO_MERGE_METHOD", + description: "The merge method to use (MERGE, SQUASH, REBASE)", + type: String, + default_value: "SQUASH" + ) + ] + end + + def self.return_value + "Returns true if auto-merge was enabled successfully, false otherwise" + end + + def self.authors + ["philprime"] + end + + def self.is_supported?(platform) + true + end + + def self.category + :source_control + end + end + end +end diff --git a/fastlane/lanes/build.rb b/fastlane/lanes/build.rb new file mode 100644 index 0000000..1088c32 --- /dev/null +++ b/fastlane/lanes/build.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# ============================================================================ +# BUILD LANES +# ============================================================================ +# Lanes for building the app in CI and local environments. +# ============================================================================ + +desc "Build the app for CI (creates archive for App Store distribution)" +lane :build_ci do + # Configure CI keychain and set match to readonly to avoid prompts + setup_ci if is_ci + + # Setup code signing + _setup_code_signing + + # Build and export the app to an archive + build_app( + project: "Flinky.xcodeproj", + scheme: "App", + archive_path: "./Flinky.xcarchive", + build_path: ".", + export_options: { + "destination" => "export", + "method" => "app-store-connect", + "provisioningProfiles" => { + "com.techprimate.Flinky" => "match AppStore com.techprimate.Flinky", + "com.techprimate.Flinky.ShareExtension" => "match AppStore com.techprimate.Flinky.ShareExtension" + }, + "signingCertificate" => "Apple Distribution", + "signingStyle" => "manual", + "teamID" => "BZ362SQ6AB" + } + ) + + # Upload the archive to Sentry for further analysis + sentry_upload_build( + auth_token: ENV["SENTRY_AUTH_TOKEN"], + org_slug: "techprimate", + project_slug: "flinky", + xcarchive_path: "./Flinky.xcarchive" + ) if is_ci +end diff --git a/fastlane/lanes/git.rb b/fastlane/lanes/git.rb new file mode 100644 index 0000000..a5132db --- /dev/null +++ b/fastlane/lanes/git.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +# ============================================================================ +# GIT & GITHUB LANES +# ============================================================================ +# Private lanes for Git operations and GitHub API interactions. +# ============================================================================ + +# Private lane: Create PR for version bump (for CI use with protected branches) +private_lane :_create_version_pr do |options| + version_number = options[:version] + build_number = options[:build] + + # Ensure we have GitHub token + github_token = ENV["GITHUB_TOKEN"] || ENV["GITHUB_APP_TOKEN"] + unless github_token + UI.user_error!("GITHUB_TOKEN or GITHUB_APP_TOKEN environment variable is required") + end + + # Create release branch name + branch_name = "release/v#{version_number}+#{build_number}" + + UI.message "Creating release branch: #{branch_name}" + + # Ensure we're on main and up to date + sh("git", "checkout", "main") + sh("git", "pull", "origin", "main") + + # Create and checkout new branch + sh("git", "checkout", "-b", branch_name) + + # Stage version changes (project.pbxproj and Root.plist are updated by build scripts) + git_add(path: ["Flinky.xcodeproj/project.pbxproj", "Targets/App/Sources/Resources/Settings.bundle/Root.plist"]) + + # Create commit with structured message + commit_message = <<~MSG + chore: Bump version to #{version_number} (#{build_number}) + + action=bump,version=#{version_number},build=#{build_number} + MSG + + git_commit( + path: ["Flinky.xcodeproj/project.pbxproj", "Targets/App/Sources/Resources/Settings.bundle/Root.plist"], + message: commit_message.strip + ) + + # Push branch to remote + sh("git", "push", "-u", "origin", branch_name) + + # Create PR + UI.message "Creating pull request..." + + pr_body = <<~BODY + ## Version Bump + + - **Version**: #{version_number} + - **Build**: #{build_number} + - **Tag**: `v#{version_number}+#{build_number}` (will be created after merge) + + This PR bumps the version and build number for the beta release. + + Once this PR is created, pushing to the release branch will trigger the deployment workflow to build and upload to TestFlight. + + This PR will be automatically merged once the deployment workflow passes (enforced by branch protection rules). + BODY + + pr_result = create_pull_request( + api_token: github_token, + repo: "techprimate/Flinky", + title: "chore: Bump version to #{version_number} (#{build_number})", + body: pr_body.strip, + head: branch_name, + base: "main" + ) + + pr_url = pr_result[:html_url] + pr_number = pr_result[:number] + + UI.success "✅ Pull request created: #{pr_url}" + + # Enable auto-merge using custom action + enable_pr_auto_merge( + pr_number: pr_number, + pr_url: pr_url, + repo: "techprimate/Flinky", + merge_method: "SQUASH" + ) + + next({ pr_url: pr_url, pr_number: pr_number, branch: branch_name }) +end + +# Private lane: Commit version changes and create git tag +private_lane :_commit_and_tag_version do |options| + version_number = options[:version] + build_number = options[:build] + + # Create version tag + version_tag = "v#{version_number}+#{build_number}" + + # Commit the version changes (project.pbxproj and Root.plist are updated by build scripts) + git_add(path: ["Flinky.xcodeproj/project.pbxproj", "Targets/App/Sources/Resources/Settings.bundle/Root.plist"]) + git_commit( + path: ["Flinky.xcodeproj/project.pbxproj", "Targets/App/Sources/Resources/Settings.bundle/Root.plist"], + message: "chore: Bump version to #{version_number} (#{build_number})" + ) + + # Create git tag for this version + add_git_tag(tag: version_tag) + + # Push the commit and tag to remote + push_to_git_remote( + remote: 'origin', + tags: true + ) +end diff --git a/fastlane/lanes/helpers.rb b/fastlane/lanes/helpers.rb new file mode 100644 index 0000000..d5ca4f0 --- /dev/null +++ b/fastlane/lanes/helpers.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +# ============================================================================ +# PRIVATE HELPER LANES +# ============================================================================ +# These lanes are internal helpers used by other lanes. +# They are not exposed in `fastlane lanes`. +# ============================================================================ + +# Private lane: Bump version number in project.pbxproj +private_lane :_bump_version do |options| + bump_type = options[:bump_type] # "major", "minor", or "patch" + + # Get current version + old_version = get_version_number( + xcodeproj: "Flinky.xcodeproj", + target: "Flinky" + ) + + # Parse version into components + version_parts = old_version.split(".").map(&:to_i) + major = version_parts[0] || 0 + minor = version_parts[1] || 0 + patch = version_parts[2] || 0 + + # Increment the appropriate part + case bump_type + when "major" + major += 1 + minor = 0 + patch = 0 + when "minor" + minor += 1 + patch = 0 + when "patch" + patch += 1 + else + UI.user_error!("Invalid bump_type: #{bump_type}. Must be 'major', 'minor', or 'patch'") + end + + new_version = "#{major}.#{minor}.#{patch}" + + # Update all MARKETING_VERSION entries in project.pbxproj + project_file = File.expand_path("../Flinky.xcodeproj/project.pbxproj") + project_content = File.read(project_file) + + # Replace all MARKETING_VERSION entries + updated_content = project_content.gsub( + /MARKETING_VERSION = #{Regexp.escape(old_version)};/, + "MARKETING_VERSION = #{new_version};" + ) + + # Write the updated content back + File.write(project_file, updated_content) + + UI.success "✅ Version bumped from #{old_version} to #{new_version}" +end + +# Private lane: Setup code signing for App Store Connect +private_lane :_setup_code_signing do + sync_code_signing( + type: "appstore", + readonly: true, + app_identifier: "com.techprimate.Flinky", + git_private_key: ENV["MATCH_GIT_PRIVATE_KEY"] + ) + sync_code_signing( + type: "appstore", + readonly: true, + app_identifier: "com.techprimate.Flinky.ShareExtension", + git_private_key: ENV["MATCH_GIT_PRIVATE_KEY"] + ) +end + +# Private lane: Increment version and build number, return both values +private_lane :_increment_version_and_build do + version_number = get_version_number( + xcodeproj: "Flinky.xcodeproj", + target: "Flinky" + ) + + increment_build_number(xcodeproj: "Flinky.xcodeproj") + build_number = get_build_number(xcodeproj: "Flinky.xcodeproj") + + next({ version: version_number, build: build_number }) +end + +# Private lane: Build the app for App Store distribution +private_lane :_build_app_for_store do + build_app( + project: "Flinky.xcodeproj", + scheme: "App", + + archive_path: "./Flinky.xcarchive", + build_path: ".", + export_options: { + "destination" => "export", + "method" => "app-store-connect", + "provisioningProfiles" => { + "com.techprimate.Flinky" => "match AppStore com.techprimate.Flinky", + "com.techprimate.Flinky.ShareExtension" => "match AppStore com.techprimate.Flinky.ShareExtension" + }, + "signingCertificate" => "Apple Distribution", + "signingStyle" => "manual", + "teamID" => "BZ362SQ6AB" + } + ) +end + +# Private lane: Validate the app before upload +private_lane :_validate_app do + deliver( + # API Key file must be located at fastlane/api-key.json + api_key_path: File.expand_path("./api-key.json"), + verify_only: true + ) +end + +# Private lane: Run a make target +private_lane :_make do |options| + UI.message "Running make target #{options[:target]}" + target = options[:target] + UI.user_error!("target is required") unless target + Dir.chdir("..") do + sh("make", target) + end + UI.success "✅ Make target #{target} run successfully!" +end diff --git a/fastlane/lanes/release.rb b/fastlane/lanes/release.rb new file mode 100644 index 0000000..3eb7d8b --- /dev/null +++ b/fastlane/lanes/release.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +# ============================================================================ +# RELEASE LANES +# ============================================================================ +# Lanes for releasing the app to TestFlight and App Store. +# ============================================================================ + +desc "Push a new beta build to TestFlight" +desc "Increments version/build, builds app, uploads to TestFlight, and commits/tags changes" +desc "Use this lane for local manual releases (commits directly to current branch)" +desc "Options: distribute_external (default: false) - whether to distribute to external groups" +lane :beta do |options| + # Configure CI keychain and set match to readonly to avoid prompts + setup_ci if is_ci + + version_info = _increment_version_and_build + version_number = version_info[:version] + build_number = version_info[:build] + + _setup_code_signing + _build_app_for_store + _validate_app + _setup_sentry_release(version: version_number, build: build_number) + + # Upload to TestFlight + upload_to_testflight( + # API Key file must be located at fastlane/api-key.json + api_key_path: File.expand_path("./api-key.json"), + app_version: version_number, + build_number: build_number, + + distribute_external: options[:distribute_external] || false, + groups: ["Friends At Sentry", "Friends Of Phil"], + beta_app_description: "Thanks for testing Flinky! + +Please focus on testing these core features: +• Creating and editing links - try various URL types (websites, deep links, etc.) +• Organizing links into lists with custom colors and symbols +• Link sharing via QR codes, AirDrop, and standard sharing +• Search functionality across your links and lists +• Link management actions (edit, delete, pin, move between lists) + +Pay special attention to: +• App performance and responsiveness +• Any crashes or unexpected behavior +• UI/UX issues or confusing interactions +• Accessibility with VoiceOver if possible + +In case you notice any issues, please use the \"Send Feedback\" button in the app, or take a screenshot, tap on the share button and select \"Share Beta Feedback\". + +Your feedback helps us improve Flinky for everyone. Thank you!" + ) + + _finalize_sentry_release(version: version_number, build: build_number) + _commit_and_tag_version(version: version_number, build: build_number) +end + +desc "Prepare release: bump version and create release branch with PR" +desc "Checks App Store Connect for published version and bumps patch if needed" +desc "Queries TestFlight for latest build number and sets next build number" +desc "Used by prepare-release.yml workflow (scheduled/manual trigger)" +desc "The PR will trigger deploy-beta.yml workflow when the release branch is pushed" +lane :prepare_release do + # Check App Store Connect and bump version if needed + version_check_result = _check_and_bump_version_if_needed + version_number = version_check_result[:version] + + # Query TestFlight for latest build number and set next one + build_number = _get_next_build_number(version: version_number) + + # Generate version files + _make(target: "generate") + + # Create PR with version changes + _create_version_pr(version: version_number, build: build_number) +end + +desc "Deploy beta build to TestFlight (triggered by release branch push)" +desc "Builds app, validates, uploads to TestFlight (internal only), and sets up Sentry release" +desc "Used by deploy-beta.yml workflow (triggered by pushes to release/** branches)" +desc "Idempotent: checks if build already exists on TestFlight and skips if already uploaded" +desc "Safe to re-run on failures - will skip upload if previous run succeeded" +lane :deploy_beta do + # Configure CI keychain and set match to readonly to avoid prompts + setup_ci if is_ci + + # Get version and build from project (already set in prepare_release) + version_number = get_version_number( + xcodeproj: "Flinky.xcodeproj", + target: "Flinky" + ) + build_number = get_build_number(xcodeproj: "Flinky.xcodeproj") + + # Check if this build is already uploaded to TestFlight + if _build_already_uploaded?(version: version_number, build: build_number) + UI.success "✅ Build #{version_number} (#{build_number}) already exists on TestFlight" + UI.success "Skipping upload - previous run succeeded. Nothing to do." + next + end + + UI.message "Build #{version_number} (#{build_number}) not yet uploaded, proceeding with deployment..." + + _setup_code_signing + _build_app_for_store + _validate_app + _setup_sentry_release(version: version_number, build: build_number) + + # Upload to TestFlight (internal build only, no external distribution) + upload_to_testflight( + # API Key file must be located at fastlane/api-key.json + api_key_path: File.expand_path("./api-key.json"), + app_version: version_number, + build_number: build_number, + + distribute_external: false, + skip_waiting_for_build_processing: false + ) + + _finalize_sentry_release(version: version_number, build: build_number) +end + +desc "Publish a new build to the App Store and submit for review" +desc "Increments version/build, builds app, uploads metadata and binary, submits for review" +desc "Commits and tags version changes after successful upload" +desc "Use this lane for production App Store releases" +lane :publish do + version_info = _increment_version_and_build + version_number = version_info[:version] + build_number = version_info[:build] + + _build_app_for_store + _validate_app + _setup_sentry_release(version: version_number, build: build_number) + + # Upload metadata and binary to App Store Connect, then submit for review + upload_to_app_store( + api_key_path: File.expand_path("./api-key.json"), + + 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_version(version: version_number, build: build_number) +end diff --git a/fastlane/lanes/sentry.rb b/fastlane/lanes/sentry.rb new file mode 100644 index 0000000..8c764df --- /dev/null +++ b/fastlane/lanes/sentry.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# ============================================================================ +# SENTRY INTEGRATION LANES +# ============================================================================ +# Private lanes for Sentry release management and symbol uploads. +# ============================================================================ + +# Private lane: Setup Sentry release (create release, upload symbols, upload build, set commits) +private_lane :_setup_sentry_release do |options| + version_number = options[:version] + build_number = options[:build] + + # Create Sentry release using the same format as the app + sentry_create_release( + auth_token: ENV["SENTRY_AUTH_TOKEN"], + org_slug: "techprimate", + project_slug: "flinky", + + app_identifier: "com.techprimate.Flinky", + version: version_number + ) + + # Upload debug symbols to Sentry + sentry_debug_files_upload( + auth_token: ENV["SENTRY_AUTH_TOKEN"], + org_slug: "techprimate", + project_slug: "flinky", + + # Wait for the server to fully process uploaded files. Errors + # can only be displayed if --wait is specified, but this will + # significantly slow down the upload process + wait: true + ) + + # Upload the xcarchive to Sentry for further analysis + sentry_upload_build( + auth_token: ENV["SENTRY_AUTH_TOKEN"], + org_slug: "techprimate", + project_slug: "flinky", + xcarchive_path: "./Flinky.xcarchive" + ) + + # Associate commits with the release + sentry_set_commits( + auth_token: ENV["SENTRY_AUTH_TOKEN"], + org_slug: "techprimate", + project_slug: "flinky", + + app_identifier: "com.techprimate.Flinky", + version: version_number, + build: build_number, + auto: true + ) +end + +# Private lane: Finalize Sentry release after successful upload +private_lane :_finalize_sentry_release do |options| + version_number = options[:version] + build_number = options[:build] + + sentry_finalize_release( + auth_token: ENV["SENTRY_AUTH_TOKEN"], + org_slug: "techprimate", + project_slug: "flinky", + + app_identifier: "com.techprimate.Flinky", + version: version_number, + build: build_number + ) +end diff --git a/fastlane/lanes/utilities.rb b/fastlane/lanes/utilities.rb new file mode 100644 index 0000000..2b58633 --- /dev/null +++ b/fastlane/lanes/utilities.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +# ============================================================================ +# UTILITY LANES +# ============================================================================ +# Lanes for various utility tasks like generating icons, screenshots, +# managing code signing, and version bumping. +# ============================================================================ + +desc "Generate app icon sizes from source image using fastlane-plugin-appicon" +desc "Generates all required iOS app icon sizes from Resources/AppIcon.png" +desc "Strips metadata to ensure git-reproducible output" +lane :generate_app_icons do + UI.message "Generating app icon sizes using fastlane-plugin-appicon" + + # Configure MiniMagick to use ImageMagick for consistent metadata handling + require 'mini_magick' + MiniMagick.configure { |config| config.cli = :imagemagick } + + # Use absolute paths to avoid plugin directory changes + project_root = File.expand_path("..") + source_image = File.join(project_root, "Resources", "AppIcon.png") + assets_path = File.join(project_root, "Targets", "App", "Sources", "Resources", "Assets.xcassets") + icon_output_dir = File.join(assets_path, "AppIcon.appiconset") + + UI.message "Using source image: #{File.basename(source_image)}" + + # Verify source image exists + unless File.exist?(source_image) + UI.user_error! "Source image not found at #{source_image}" + end + + # Generate iOS app icons (iPhone, iPad, App Store) + appicon( + appicon_image_file: source_image, + appicon_devices: [:iphone, :ipad, :ios_marketing], + appicon_path: assets_path, + remove_alpha: true, + minimagick_cli: "imagemagick" + ) + + UI.message "Stripping metadata from generated icons to ensure git-reproducible output" + + # Strip metadata from all generated icon files + Dir.glob(File.join(icon_output_dir, "*.png")).each do |icon_path| + UI.verbose "Processing: #{File.basename(icon_path)}" + + begin + image = MiniMagick::Image.open(icon_path) + image.combine_options do |b| + # Strip all existing metadata + b << "-strip" + + # Set consistent comment + b << "-set" << "comment" << "Generated using fastlane-plugin-appicon" + + # Exclude PNG time chunks to prevent timestamp variations + b << "-define" << "png:exclude-chunks=tIME" + + # Set consistent timestamps to prevent git detecting changes + timestamp = "2024-01-01T00:00:00Z" + b << "-set" << "date:create" << timestamp + b << "-set" << "date:modify" << timestamp + b << "-set" << "date:timestamp" << timestamp + + # Set consistent resolution info + b.density "72x72" + b << "-set" << "units" << "PixelsPerInch" + + # Set consistent user time + b << "-set" << "user:time" << "0" + end + + # Write the processed image back + image.write(icon_path) + + rescue => e + UI.error "Failed to process #{icon_path}: #{e.message}" + end + end + + UI.success "✅ All app icon sizes generated and metadata stripped successfully!" + UI.message "Icons generated in: Targets/App/Sources/Resources/Assets.xcassets/AppIcon.appiconset/" + UI.message "📝 Icons are now git-reproducible (no metadata changes on regeneration)" +end + +desc "Generate screenshots" +desc "Captures localized screenshots for App Store using ScreenshotUITests scheme" +desc "Generates screenshots for iPhone and iPad devices" +lane :generate_screenshots do + UI.message "Generating screenshots" + + capture_screenshots( + scheme: "ScreenshotUITests", + 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 + ], + languages: ["en-US"], + + clear_previous_screenshots: true, + concurrent_simulators: true, + skip_open_summary: true, + + reinstall_app: true, + override_status_bar: true, + localize_simulator: true, + disable_slide_to_type: true, + number_of_retries: 0 + ) + + UI.success "✅ Screenshots generated successfully!" + UI.message "Screenshots generated in: ScreenshotUITests/Screenshots/" +end + +desc "Upload metadata to App Store Connect" +desc "Uploads app descriptions, screenshots, and other metadata without building/uploading binary" +desc "Useful for updating store listing without creating a new build" +lane :upload_metadata do + UI.message "Uploading metadata to App Store Connect" + upload_to_app_store( + api_key_path: File.expand_path("./api-key.json"), + + skip_binary_upload: true, + overwrite_screenshots: 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"] + } + ) +end + +desc "Setup code signing certificates and provisioning profiles" +desc "Syncs development and App Store certificates/profiles for app and share extension" +desc "Uses match to manage certificates and profiles" +lane :setup_code_signing do + UI.message "Syncing code signing..." + sync_code_signing( + type: "development", + app_identifier: "com.techprimate.Flinky" + ) + sync_code_signing( + type: "appstore", + app_identifier: "com.techprimate.Flinky" + ) + sync_code_signing( + type: "development", + app_identifier: "com.techprimate.Flinky.ShareExtension" + ) + sync_code_signing( + type: "appstore", + app_identifier: "com.techprimate.Flinky.ShareExtension" + ) + UI.success "✅ Code signing synced successfully!" +end + +desc "Bump the major version number (e.g., 1.1.2 -> 2.0.0)" +desc "Increments major version and resets minor/patch to 0" +desc "Regenerates version files after bumping" +desc "Use for breaking changes or major feature releases" +lane :bump_version_major do + _bump_version(bump_type: "major") + _make(target: "generate") +end + +desc "Bump the minor version number (e.g., 1.1.2 -> 1.2.0)" +desc "Increments minor version and resets patch to 0" +desc "Regenerates version files after bumping" +desc "Use for new features or significant improvements" +lane :bump_version_minor do + _bump_version(bump_type: "minor") + _make(target: "generate") +end + +desc "Bump the patch version number (e.g., 1.1.2 -> 1.1.3)" +desc "Increments patch version number" +desc "Regenerates version files after bumping" +desc "Use for bug fixes and minor updates" +lane :bump_version_patch do + _bump_version(bump_type: "patch") + _make(target: "generate") +end diff --git a/fastlane/lanes/version.rb b/fastlane/lanes/version.rb new file mode 100644 index 0000000..5e4faf2 --- /dev/null +++ b/fastlane/lanes/version.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +# ============================================================================ +# VERSION MANAGEMENT LANES +# ============================================================================ +# Private lanes for version checking and management with App Store Connect. +# ============================================================================ + +# Private lane: Check App Store Connect and bump patch version if current version is already published +private_lane :_check_and_bump_version_if_needed do + current_version = get_version_number( + xcodeproj: "Flinky.xcodeproj", + target: "Flinky" + ) + + UI.message "Checking App Store Connect for published version..." + + begin + # Get the published version from App Store Connect + # This action sets lane_context[SharedValues::LATEST_VERSION] with the version number + app_store_build_number( + api_key_path: File.expand_path("./api-key.json"), + live: true + ) + + # Get the published version from lane context + published_version = lane_context[SharedValues::LATEST_VERSION] + + unless published_version + raise "Could not retrieve published version from App Store Connect" + end + + UI.message "Published version on App Store Connect: #{published_version}" + + # Compare versions semantically + if _version_already_published?(current_version, published_version) + UI.important "Version #{current_version} is already published. Bumping patch version..." + _bump_version(bump_type: "patch") + _make(target: "generate") + + # Get the new version after bumping + new_version = get_version_number( + xcodeproj: "Flinky.xcodeproj", + target: "Flinky" + ) + UI.success "✅ Version bumped from #{current_version} to #{new_version}" + next({ version: new_version, bumped: true }) + else + UI.success "✅ Version #{current_version} is not yet published, using current version" + next({ version: current_version, bumped: false }) + end + rescue => e + UI.important "⚠️ Failed to check App Store Connect: #{e.message}" + UI.important "Falling back to current version without bumping" + next({ version: current_version, bumped: false }) + end +end + +# Private lane: Compare versions to check if current version is already published +private_lane :_version_already_published? do |current_version, published_version| + # Parse version strings into arrays of integers + current_parts = current_version.split(".").map(&:to_i) + published_parts = published_version.split(".").map(&:to_i) + + # Compare major, minor, patch + (0..2).each do |i| + current_part = current_parts[i] || 0 + published_part = published_parts[i] || 0 + + if current_part < published_part + next false # Current version is lower, not published + elsif current_part > published_part + next false # Current version is higher, not published yet + end + end + + # Versions are equal, so current version is already published + next true +end + +# Private lane: Query TestFlight for latest build number and return next one +# Sets the build number in the project to the next available number +private_lane :_get_next_build_number do |options| + version_number = options[:version] + + UI.message "Querying TestFlight for latest build number for version #{version_number}..." + + begin + # Query TestFlight for the latest build number for this version + latest_testflight_build_number( + api_key_path: File.expand_path("./api-key.json"), + version: version_number + ) + + # Get the latest build number from lane context + latest_build = lane_context[SharedValues::LATEST_TESTFLIGHT_BUILD_NUMBER] + + if latest_build + next_build = latest_build.to_i + 1 + UI.message "Latest build on TestFlight: #{latest_build}, using next: #{next_build}" + else + next_build = 1 + UI.message "No builds found on TestFlight for version #{version_number}, starting at 1" + end + + # Set the build number in the project + increment_build_number( + xcodeproj: "Flinky.xcodeproj", + build_number: next_build.to_s + ) + + UI.success "✅ Build number set to #{next_build}" + next next_build.to_s + rescue => e + UI.important "⚠️ Could not query TestFlight: #{e.message}" + UI.important "Incrementing from current build number as fallback" + increment_build_number(xcodeproj: "Flinky.xcodeproj") + build_number = get_build_number(xcodeproj: "Flinky.xcodeproj") + next build_number + end +end + +# Private lane: Check if a specific version+build already exists on TestFlight +private_lane :_build_already_uploaded? do |options| + version_number = options[:version] + build_number = options[:build] + + UI.message "Checking if build #{version_number} (#{build_number}) exists on TestFlight..." + + begin + # Query TestFlight for the latest build number for this version + latest_testflight_build_number( + api_key_path: File.expand_path("./api-key.json"), + version: version_number + ) + + latest_build = lane_context[SharedValues::LATEST_TESTFLIGHT_BUILD_NUMBER] + + if latest_build && latest_build.to_i >= build_number.to_i + UI.message "Build #{build_number} already exists (latest is #{latest_build})" + next true + else + UI.message "Build #{build_number} does not exist yet (latest is #{latest_build || 'none'})" + next false + end + rescue => e + UI.important "⚠️ Could not check TestFlight: #{e.message}" + UI.important "Assuming build does not exist, proceeding with upload" + next false + end +end