# XuqmGroup Update Service — iOS Fastlane Release Lane # # Requires: fastlane (gem install fastlane) — standard iOS platform toolchain # Usage (from your iOS project root): # fastlane xuqm_release # # Config: add a .env.xuqm file to your project root with: # XUQM_SERVER_URL=https://update.dev.xuqinmin.com # XUQM_APP_KEY=your-app-key # XUQM_API_TOKEN=your-api-token # XUQM_SCHEME=MyApp # XUQM_WORKSPACE=MyApp.xcworkspace # XUQM_BUNDLE_ID=com.example.myapp # XUQM_STORE_TARGETS=APP_STORE # comma-separated, e.g. "APP_STORE" # XUQM_AUTO_PUBLISH_AFTER_REVIEW=false # XUQM_PUBLISH_IMMEDIATELY=false # XUQM_WEBHOOK_URL= # optional # XUQM_SCHEDULED_PUBLISH_AT= # optional, ISO datetime # # For App Store Connect API (used by upload_to_app_store): # APP_STORE_CONNECT_API_KEY_ID=XXXXXXXXXX # APP_STORE_CONNECT_API_ISSUER_ID=xxxx-xxxx-xxxx-xxxx # APP_STORE_CONNECT_API_KEY_FILEPATH=~/.appstoreconnect/AuthKey_XXXXXXXXXX.p8 require 'net/http' require 'json' require 'uri' default_platform(:ios) platform :ios do desc "Build, upload to XuqmGroup update service, and submit to App Store" lane :xuqm_release do server_url = ENV.fetch("XUQM_SERVER_URL") app_key = ENV.fetch("XUQM_APP_KEY") api_token = ENV.fetch("XUQM_API_TOKEN") tenant_url = ENV.fetch("XUQM_TENANT_URL", "").strip scheme = ENV.fetch("XUQM_SCHEME") workspace = ENV.fetch("XUQM_WORKSPACE") bundle_id = ENV.fetch("XUQM_BUNDLE_ID", "") tenant_cfg = xuqm_fetch_sdk_config(tenant_url, app_key, "IOS") store_targets = ENV.fetch("XUQM_STORE_TARGETS", tenant_cfg.fetch("updateDefaultStoreTargets", "APP_STORE")) auto_publish = ENV.fetch("XUQM_AUTO_PUBLISH_AFTER_REVIEW", tenant_cfg.fetch("updateDefaultAutoPublishAfterReview", "false")) == "true" publish_immediately = ENV.fetch("XUQM_PUBLISH_IMMEDIATELY", tenant_cfg.fetch("updateDefaultPublishImmediately", "false")) == "true" webhook_url = ENV.fetch("XUQM_WEBHOOK_URL", tenant_cfg.fetch("updateDefaultWebhookUrl", "")) scheduled_at = ENV.fetch("XUQM_SCHEDULED_PUBLISH_AT", tenant_cfg.fetch("updateDefaultScheduledPublishAt", "")) publish_mode = ENV.fetch("XUQM_PUBLISH_MODE", tenant_cfg.fetch("updateDefaultPublishMode", "")).to_s.strip.upcase dry_run = ENV.fetch("XUQM_DRY_RUN", "false") == "true" || publish_mode == "DRY_RUN" || !tenant_cfg.fetch("updateEnabled", true) allow_version_mismatch = ENV.fetch("XUQM_ALLOW_VERSION_MISMATCH", "false") == "true" # ── 1. Read local version ──────────────────────────────────────────── version_name = get_version_number(target: scheme) version_code = get_build_number.to_i UI.message("Local version: #{version_name} (#{version_code}), appKey: #{app_key}") # ── 2. Check server latest ─────────────────────────────────────────── server_version_code = xuqm_get_latest_version_code(server_url, app_key, api_token, "IOS") UI.message("Server latest versionCode: #{server_version_code}") if version_code <= server_version_code if !allow_version_mismatch UI.message("Local version is not greater than server latest. Please enter corrected release version info.") version_name_input = prompt(text: "Release version name [#{version_name}]: ", ci_input: "").to_s.strip version_code_input = prompt(text: "Release version code [#{version_code}]: ", ci_input: "").to_s.strip version_name = version_name_input unless version_name_input.empty? version_code = version_code_input.to_i unless version_code_input.empty? end UI.user_error!( "Local versionCode (#{version_code}) must be greater than server (#{server_version_code}). " \ "Please bump CFBundleVersion before releasing." ) if version_code <= server_version_code end if version_name != get_version_number(target: scheme) increment_version_number(version_number: version_name) end if version_code != get_build_number.to_i increment_build_number(build_number: version_code.to_s) end # ── 3. Archive & export IPA ────────────────────────────────────────── archive_path = File.join(Dir.tmpdir, "xuqm_#{scheme}.xcarchive") ipa_output = File.join(Dir.tmpdir, "xuqm_#{scheme}_ipa") build_app( scheme: scheme, workspace: workspace, archive_path: archive_path, output_directory: ipa_output, export_method: "app-store", silent: false, ) ipa_path = Dir.glob("#{ipa_output}/*.ipa").first UI.user_error!("IPA not found in #{ipa_output}") unless ipa_path UI.success("IPA: #{ipa_path}") if dry_run UI.message("Dry-run summary:") UI.message(" updateEnabled=#{tenant_cfg.fetch("updateEnabled", true)}") UI.message(" releaseVersion=#{version_name} (#{version_code})") UI.message(" publishMode=#{publish_mode.empty? ? "MANUAL" : publish_mode}") UI.message(" publishImmediately=#{publish_immediately}") UI.message(" autoPublishAfterReview=#{auto_publish}") UI.message(" scheduledAt=#{scheduled_at.empty? ? "-" : scheduled_at}") UI.message(" webhookUrl=#{webhook_url.empty? ? "-" : webhook_url}") UI.message(" storeTargets=#{store_targets.empty? ? "-" : store_targets}") UI.message("Dry-run completed. No upload performed.") next end # ── 4. Upload to XuqmGroup update service ──────────────────────────── change_log = prompt(text: "Release notes: ", ci_input: "") if publish_mode.empty? publish_mode = if publish_immediately "NOW" elsif !scheduled_at.empty? "SCHEDULED" elsif auto_publish "AUTO_REVIEW" else prompt(text: "Publish mode (MANUAL/NOW/SCHEDULED/AUTO_REVIEW) [MANUAL]: ", ci_input: "").to_s.strip.upcase end end dry_run = true if publish_mode == "DRY_RUN" if !scheduled_at.empty? publish_immediately = false auto_publish = false end if publish_mode == "SCHEDULED" && scheduled_at.empty? scheduled_at = prompt(text: "Scheduled publish time (ISO datetime): ", ci_input: "").to_s.strip end if publish_mode == "NOW" publish_immediately = true auto_publish = false elsif publish_mode == "AUTO_REVIEW" auto_publish = true publish_immediately = false end version_id = xuqm_upload_ipa( server_url: server_url, app_key: app_key, api_token: api_token, ipa_path: ipa_path, version_name: version_name, version_code: version_code, change_log: change_log, package_name: bundle_id, store_targets: store_targets.empty? ? [] : store_targets.split(",").map(&:strip), auto_publish_after_review: auto_publish, scheduled_publish_at: scheduled_at, webhook_url: webhook_url, ) UI.success("Uploaded, version ID: #{version_id}") # ── 5. Submit to App Store via App Store Connect API ───────────────── if store_targets.include?("APP_STORE") UI.message("Submitting to App Store Connect...") # Use Fastlane's built-in action — handles JWT auth, upload, and submission # Requires APP_STORE_CONNECT_API_KEY_* env vars api_key = app_store_connect_api_key( key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"), issuer_id: ENV.fetch("APP_STORE_CONNECT_API_ISSUER_ID"), key_filepath: ENV.fetch("APP_STORE_CONNECT_API_KEY_FILEPATH"), ) upload_to_app_store( api_key: api_key, ipa: ipa_path, submit_for_review: true, force: true, automatic_release: auto_publish, skip_metadata: true, skip_screenshots: true, run_precheck_before_submit: false, ) # Notify update service that we've submitted xuqm_post( url: "#{server_url}/api/v1/updates/store/app/#{version_id}/submit", token: api_token, body: { storeTypes: ["APP_STORE"] }.to_json, ) UI.success("App Store submission done. Review status will update via webhook or manually.") end # ── 6. Trigger server-side store submission for other stores ────────── other_stores = store_targets.split(",").map(&:strip).reject { |s| s == "APP_STORE" } unless other_stores.empty? xuqm_post( url: "#{server_url}/api/v1/updates/store/app/#{version_id}/execute-submit", token: api_token, body: { storeTypes: other_stores }.to_json, ) end if publish_immediately && !auto_publish && scheduled_at.empty? xuqm_post( url: "#{server_url}/api/v1/updates/app/#{version_id}/publish", token: api_token, body: {}.to_json, ) UI.success("Update service version published immediately.") end UI.success("Release complete. Version ID: #{version_id}") if scheduled_at.empty? if auto_publish UI.message("Will auto-publish after all store reviews pass.") elsif publish_immediately UI.message("Published immediately in update service.") else UI.message("Publish manually or wait for auto-publish.") end else UI.message("Will publish at #{scheduled_at}") end end # ── Private helpers ────────────────────────────────────────────────────── private_lane :xuqm_fetch_sdk_config do |options| tenant_url = options[:tenant_url].to_s.strip return {} if tenant_url.empty? uri = URI("#{tenant_url}/api/sdk/config?appId=#{options[:app_key]}&platform=#{options[:platform]}") req = Net::HTTP::Get.new(uri) resp = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(req) } body = JSON.parse(resp.body) body["data"] || {} rescue => e UI.message("Failed to load tenant defaults: #{e.message}") {} end private_lane :xuqm_get_latest_version_code do |options| uri = URI("#{options[:server_url]}/api/v1/updates/app/list?appId=#{options[:app_key]}&platform=#{options[:platform]}") req = Net::HTTP::Get.new(uri) req["Authorization"] = "Bearer #{options[:api_token]}" resp = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(req) } data = JSON.parse(resp.body) codes = (data.dig("data") || []).filter_map { |v| v["versionCode"] } codes.max || 0 end private_lane :xuqm_upload_ipa do |options| require 'net/http/post/multipart' uri = URI("#{options[:server_url]}/api/v1/updates/app/upload") request = Net::HTTP::Post::Multipart.new(uri.path, "appId" => options[:app_key], "platform" => "IOS", "versionName" => options[:version_name], "versionCode" => options[:version_code].to_s, "changeLog" => options[:change_log].to_s, "forceUpdate" => "false", "packageName" => options[:package_name].to_s, "autoPublishAfterReview" => options[:auto_publish_after_review].to_s, "storeSubmitTargets" => options[:store_targets].empty? ? "" : options[:store_targets].to_json, "scheduledPublishAt" => options[:scheduled_publish_at].to_s, "webhookUrl" => options[:webhook_url].to_s, "apkFile" => UploadIO.new(File.open(options[:ipa_path]), "application/octet-stream", File.basename(options[:ipa_path])), ) request["Authorization"] = "Bearer #{options[:api_token]}" resp = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(request) } data = JSON.parse(resp.body) data.dig("data", "id") || UI.user_error!("Upload failed: #{resp.body}") end private_lane :xuqm_post do |options| uri = URI(options[:url]) req = Net::HTTP::Post.new(uri) req["Authorization"] = "Bearer #{options[:token]}" req["Content-Type"] = "application/json" req.body = options[:body] Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(req) } end end