default_platform(:ios) # Credentials: App Store Connect API key via `pass`. # The api_key block uses the key inline — no session/2FA needed. platform :ios do EXPORT_OPTIONS = { method: "app-store", signingStyle: "manual", manageAppVersionAndBuildNumber: false, provisioningProfiles: { "com.alexandrevazquez.mealmood" => "com.alexandrevazquez.mealmood AppStore", "com.alexandrevazquez.mealmood.widget" => "com.alexandrevazquez.mealmood.widget AppStore" } } def asc_api_key app_store_connect_api_key( key_id: `pass show appstore/api-key-id`.strip, issuer_id: `pass show appstore/issuer-id`.strip, key_content: `pass show appstore/api-key-p8`.strip, in_house: false ) end desc "Push a new beta build to TestFlight" lane :beta do increment_build_number(xcodeproj: "MealMood.xcodeproj") build_app( scheme: "MealMood", export_method: "app-store", sdk: "iphoneos26.5", export_options: EXPORT_OPTIONS ) upload_to_testflight( api_key: asc_api_key, skip_waiting_for_build_processing: true ) end # Firebase arrives via SPM, so upload-symbols sits in DerivedData instead of # the ./Pods path fastlane looks in by default. def crashlytics_upload_symbols_binary pattern = File.expand_path( "~/Library/Developer/Xcode/DerivedData/MealMood-*/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols" ) found = Dir.glob(pattern) if found.empty? UI.user_error!("upload-symbols not found. Build the project once so SPM checks out firebase-ios-sdk.") end found.max_by { |path| File.mtime(path) } end # Every dSYM (app + widget) of the most recently archived build. def latest_archive_dsyms pattern = File.expand_path("~/Library/Developer/Xcode/Archives/*/MealMood*.xcarchive/dSYMs/*.dSYM") by_archive = Dir.glob(pattern).group_by { |path| path[/.*\.xcarchive/] } newest = by_archive.max_by { |archive, _| File.mtime(archive) } newest ? newest.last : [] end # The shipped version lives in the Info.plist, not in MARKETING_VERSION. # Absolute path: the lane's working directory is not guaranteed to be the repo root. def shipped_app_version plist = File.expand_path("../MealMood/Resources/Info.plist", __dir__) value = `/usr/bin/plutil -extract CFBundleShortVersionString raw "#{plist}"`.strip UI.user_error!("Could not read the app version from #{plist}") if value.empty? value end # asc_api_key returns fastlane's option hash, not a Spaceship token. def connect_api_app require 'spaceship' Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create( key_id: `pass show appstore/api-key-id`.strip, issuer_id: `pass show appstore/issuer-id`.strip, key: `pass show appstore/api-key-p8`.strip ) Spaceship::ConnectAPI::App.find("com.alexandrevazquez.mealmood") end # `deliver` does not create the App Store version record — it fails with # "could not find an editable version for 'IOS'" when the previous version is # already READY_FOR_SALE and nothing is open for editing. def ensure_editable_version(version) app = connect_api_app editable = app.get_edit_app_store_version(platform: "IOS") if editable && editable.version_string != version # ensure_version! would silently renumber it — say so before it happens. UI.important("App Store Connect had #{editable.version_string} open for editing; renaming it to #{version}") end created = app.ensure_version!(version, platform: "IOS") UI.success(created ? "App Store version #{version} ready" : "App Store version #{version} already editable") end desc "Show the latest TestFlight beta feedback (tester reports and crashes)" lane :feedback do |options| app = connect_api_app # spaceship's get_beta_feedback hits v1/betaFeedbacks, a private endpoint # Apple has since removed. These are the current public ones. client = Spaceship::ConnectAPI.test_flight_request_client limit = (options[:limit] || 10).to_i ["v1/apps/#{app.id}/betaFeedbackCrashSubmissions", "v1/apps/#{app.id}/betaFeedbackScreenshotSubmissions"].each do |path| UI.header(path) begin resp = client.get(path, { "include" => "build,tester", "limit" => limit, "sort" => "-createdDate" }) rows = resp.body["data"] || [] # Resolve build ids so each report says which build it came from. builds = {} (resp.body["included"] || []).each do |inc| builds[inc["id"]] = inc.dig("attributes", "version") if inc["type"] == "builds" end UI.important("none") if rows.empty? rows.each do |row| a = row["attributes"] || {} build = builds[row.dig("relationships", "build", "data", "id")] UI.message("── #{a['createdDate']} — build #{build || '?'} — #{a['deviceModel']} — #{a['osVersion']} — id #{row['id']}") UI.message(" #{a['comment']}") if a["comment"] end rescue => e UI.error("#{path}: #{e.message[0, 200]}") end end end desc "Download the crash log of a TestFlight feedback submission (id from `feedback`)" lane :crashlog do |options| UI.user_error!("pass id:") unless options[:id] connect_api_app client = Spaceship::ConnectAPI.test_flight_request_client resp = client.get("v1/betaFeedbackCrashSubmissions/#{options[:id]}/crashLog", {}) # The log comes back inline as logText, not as a download URL. text = resp.body.dig("data", "attributes", "logText") UI.user_error!("No logText in response: #{resp.body.to_s[0, 300]}") unless text out = File.expand_path("../crashlog-#{options[:id]}.crash", __dir__) File.write(out, text) UI.success("Saved to #{out}") end desc "Show the App Store state of each version (review status, release type)" lane :status do app = connect_api_app app.get_app_store_versions(includes: "build").each do |v| UI.message("#{v.version_string} — #{v.app_store_state} — release: #{v.release_type} — build: #{v.build&.version}") end UI.header("Recent TestFlight builds") Spaceship::ConnectAPI::Build.all(app_id: app.id, limit: 5, sort: "-uploadedDate").each do |b| UI.message("build #{b.version} — #{b.processing_state} — expired: #{b.expired} — #{b.uploaded_date}") end end # For a build that is already on TestFlight: `release` would rebuild it and # `publish` never submits. Screenshots are left alone — they carry over. desc "Submit an already-uploaded build for App Store review (automatic release)" lane :submit do |options| UI.user_error!("pass build:") unless options[:build] version = options[:version] || shipped_app_version ensure_editable_version(version) upload_to_app_store( api_key: asc_api_key, app_version: version, build_number: options[:build], skip_binary_upload: true, skip_metadata: false, skip_screenshots: true, overwrite_screenshots: false, submit_for_review: true, automatic_release: true, force: true, precheck_include_in_app_purchases: false, run_precheck_before_submit: false ) end # Safety net for the Xcode build phase, which only uploads the dSYM produced # locally. Run it once App Store Connect has finished processing the build — # `beta` uses skip_waiting_for_build_processing, so right after an upload the # dSYMs are not there yet and this would download nothing. desc "Download processed dSYMs from App Store Connect and send them to Crashlytics" lane :refresh_dsyms do |options| download_dsyms( api_key: asc_api_key, version: options[:version] || "latest", build_number: options[:build] ) dsyms = lane_context[SharedValues::DSYM_PATHS] || [] if dsyms.empty? # Expected for a post-bitcode app: Apple recompiles nothing, so it keeps # no dSYM of its own. Fall back to the ones the local archive produced. dsyms = latest_archive_dsyms UI.important("App Store Connect has no dSYMs for this build; using the latest local archive.") end if dsyms.empty? UI.important("No dSYMs found either — nothing to upload.") next end upload_symbols_to_crashlytics( gsp_path: "MealMood/Resources/GoogleService-Info.plist", binary_path: crashlytics_upload_symbols_binary, dsym_paths: dsyms ) clean_build_artifacts end desc "Upload metadata + localized screenshots for an existing TestFlight build (no rebuild, no submit)" lane :publish do |options| version = options[:version] || "1.2.0" build = options[:build] || "55" upload_to_app_store( api_key: asc_api_key, app_version: version, build_number: build, skip_binary_upload: true, skip_metadata: false, skip_screenshots: false, overwrite_screenshots: true, submit_for_review: false, automatic_release: false, precheck_include_in_app_purchases: false, force: true, run_precheck_before_submit: false ) end desc "Push a new release build to the App Store" lane :release do increment_build_number(xcodeproj: "MealMood.xcodeproj") build_app( scheme: "MealMood", sdk: "iphoneos26.5", export_options: EXPORT_OPTIONS ) upload_to_app_store( api_key: asc_api_key, automatic_release: true, force: true ) end end