2026-04-29 00:37:02 +08:00
|
|
|
# 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
|
2026-04-29 15:46:39 +08:00
|
|
|
# XUQM_APP_KEY=your-app-key
|
2026-04-29 00:37:02 +08:00
|
|
|
# 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
|
2026-04-29 15:46:39 +08:00
|
|
|
# XUQM_PUBLISH_IMMEDIATELY=false
|
2026-04-29 00:37:02 +08:00
|
|
|
# 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")
|
2026-04-29 15:46:39 +08:00
|
|
|
app_key = ENV.fetch("XUQM_APP_KEY")
|
2026-04-29 00:37:02 +08:00
|
|
|
api_token = ENV.fetch("XUQM_API_TOKEN")
|
2026-04-29 17:35:52 +08:00
|
|
|
tenant_url = ENV.fetch("XUQM_TENANT_URL", "").strip
|
2026-04-29 00:37:02 +08:00
|
|
|
scheme = ENV.fetch("XUQM_SCHEME")
|
|
|
|
|
workspace = ENV.fetch("XUQM_WORKSPACE")
|
|
|
|
|
bundle_id = ENV.fetch("XUQM_BUNDLE_ID", "")
|
2026-04-29 17:35:52 +08:00
|
|
|
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"
|
2026-04-29 00:37:02 +08:00
|
|
|
|
|
|
|
|
# ── 1. Read local version ────────────────────────────────────────────
|
|
|
|
|
version_name = get_version_number(target: scheme)
|
|
|
|
|
version_code = get_build_number.to_i
|
2026-04-29 15:46:39 +08:00
|
|
|
UI.message("Local version: #{version_name} (#{version_code}), appKey: #{app_key}")
|
2026-04-29 00:37:02 +08:00
|
|
|
|
|
|
|
|
# ── 2. Check server latest ───────────────────────────────────────────
|
2026-04-29 15:46:39 +08:00
|
|
|
server_version_code = xuqm_get_latest_version_code(server_url, app_key, api_token, "IOS")
|
2026-04-29 00:37:02 +08:00
|
|
|
UI.message("Server latest versionCode: #{server_version_code}")
|
|
|
|
|
|
|
|
|
|
if version_code <= server_version_code
|
2026-04-29 17:35:52 +08:00
|
|
|
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
|
2026-04-29 00:37:02 +08:00
|
|
|
UI.user_error!(
|
|
|
|
|
"Local versionCode (#{version_code}) must be greater than server (#{server_version_code}). " \
|
|
|
|
|
"Please bump CFBundleVersion before releasing."
|
2026-04-29 17:35:52 +08:00
|
|
|
) 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)
|
2026-04-29 00:37:02 +08:00
|
|
|
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}")
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
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
|
|
|
|
|
|
2026-04-29 00:37:02 +08:00
|
|
|
# ── 4. Upload to XuqmGroup update service ────────────────────────────
|
|
|
|
|
change_log = prompt(text: "Release notes: ", ci_input: "")
|
2026-04-29 17:35:52 +08:00
|
|
|
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
|
2026-04-29 00:37:02 +08:00
|
|
|
version_id = xuqm_upload_ipa(
|
|
|
|
|
server_url: server_url,
|
2026-04-29 15:46:39 +08:00
|
|
|
app_key: app_key,
|
2026-04-29 00:37:02 +08:00
|
|
|
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
|
|
|
|
|
|
2026-04-29 15:46:39 +08:00
|
|
|
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
|
|
|
|
|
|
2026-04-29 00:37:02 +08:00
|
|
|
UI.success("Release complete. Version ID: #{version_id}")
|
2026-04-29 15:46:39 +08:00
|
|
|
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
|
2026-04-29 00:37:02 +08:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# ── Private helpers ──────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
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
|
|
|
|
|
|
2026-04-29 00:37:02 +08:00
|
|
|
private_lane :xuqm_get_latest_version_code do |options|
|
2026-04-29 15:46:39 +08:00
|
|
|
uri = URI("#{options[:server_url]}/api/v1/updates/app/list?appId=#{options[:app_key]}&platform=#{options[:platform]}")
|
2026-04-29 00:37:02 +08:00
|
|
|
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,
|
2026-04-29 15:46:39 +08:00
|
|
|
"appId" => options[:app_key],
|
2026-04-29 00:37:02 +08:00
|
|
|
"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
|