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", "com.alexandrevazquez.mealmood.watchkitapp" => "com.alexandrevazquez.mealmood.watchkitapp AppStore", "com.alexandrevazquez.mealmood.watchkitapp.widget" => "com.alexandrevazquez.mealmood.watchkitapp.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 # A field missing from the CloudKit schema doesn't crash anything — it just # never syncs, silently. Nine of them drifted out of Production between 2.0 # and 2.1.2 before anyone noticed (issue #38), so shipping now checks first. def check_cloudkit_schema(blocking:) script = File.expand_path("../scripts/check_cloudkit_schema.py", __dir__) ok = system("python3", script, "--environment", "production") return if ok if blocking UI.user_error!("CloudKit Production schema is behind the models — deploy it before shipping (see the output above).") else UI.important("CloudKit Production schema is behind the models. Fine for TestFlight, but deploy it before submitting to the App Store.") end end desc "Check the deployed CloudKit schema against the SwiftData models" lane :check_schema do check_cloudkit_schema(blocking: true) end desc "Push a new beta build to TestFlight" lane :beta do check_cloudkit_schema(blocking: false) increment_build_number(xcodeproj: "MealMood.xcodeproj") # No `sdk:` here: forcing one SDK globally breaks the watchOS targets, # which must build against watchos within the same archive. build_app( scheme: "MealMood", export_method: "app-store", destination: "generic/platform=iOS", 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] check_cloudkit_schema(blocking: true) 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 check_cloudkit_schema(blocking: true) 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