Files
openclaw/apps/ios/fastlane/Fastfile
2026-06-23 21:32:26 -05:00

1441 lines
49 KiB
Ruby

require "shellwords"
require "open3"
require "json"
require "fileutils"
require "tmpdir"
require "tempfile"
require "cgi"
require "digest/md5"
default_platform(:ios)
APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE = "openclaw-app-store-connect-key"
DEFAULT_SNAPSHOT_DEVICE_FAMILIES = [
{
label: "iPhone",
patterns: [
/\AiPhone .* Pro Max\z/,
/\AiPhone .* Plus\z/,
/\AiPhone .*\z/
]
},
{
label: "13-inch iPad",
patterns: [
/\AiPad Pro 13-inch/,
/\AiPad Air 13-inch/,
/\AiPad .*13-inch/
]
}
].freeze
DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41"
SNAPSHOT_STATUS_BAR_ARGUMENTS = "--time 09:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100".freeze
REQUIRED_SCREENSHOT_FAMILIES = {
"iPhone" => /iPhone/,
"13-inch iPad" => /iPad (Air|Pro) 13-inch/
}.freeze
PUBLIC_METADATA_FILENAMES = [
"description.txt",
"keywords.txt",
"marketing_url.txt",
"name.txt",
"privacy_url.txt",
"promotional_text.txt",
"release_notes.txt",
"subtitle.txt",
"support_url.txt"
].freeze
APP_REVIEW_NOTES_METADATA_FILENAMES = [
"notes.txt",
"review_notes.txt"
].freeze
APP_STORE_SCREENSHOT_LIMIT_PER_SET = 10
APP_STORE_SCREENSHOT_SET_DELETE_TIMEOUT_SECONDS = 120
APP_STORE_SCREENSHOT_PROCESSING_TIMEOUT_SECONDS = 3600
APP_STORE_SCREENSHOT_PROCESSING_POLL_SECONDS = 5
def load_env_file(path)
return unless File.exist?(path)
File.foreach(path) do |line|
stripped = line.strip
next if stripped.empty? || stripped.start_with?("#")
key, value = stripped.split("=", 2)
next if key.nil? || key.empty? || value.nil?
ENV[key] = value if ENV[key].nil? || ENV[key].strip.empty?
end
end
def env_present?(value)
!value.nil? && !value.strip.empty?
end
def clear_empty_env_var(key)
return unless ENV.key?(key)
ENV.delete(key) unless env_present?(ENV[key])
end
def screenshot_upload_requested?
ENV["DELIVER_SCREENSHOTS"] == "1"
end
def release_notes_upload_requested?
ENV["DELIVER_RELEASE_NOTES"] == "1"
end
def validate_required_screenshots!(paths)
missing_families = REQUIRED_SCREENSHOT_FAMILIES.filter_map do |name, pattern|
name unless paths.any? { |path| File.basename(path).match?(pattern) }
end
return if missing_families.empty?
UI.user_error!("DELIVER_SCREENSHOTS=1 but screenshots are missing for: #{missing_families.join(', ')}.")
end
def snapshot_devices
raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip
return default_snapshot_devices if raw.empty?
raw.split(",").map(&:strip).reject(&:empty?)
end
def default_snapshot_devices
names = available_simulator_devices.map { |device| device["name"].to_s }.reject(&:empty?).uniq
DEFAULT_SNAPSHOT_DEVICE_FAMILIES.map do |family|
match = family.fetch(:patterns).filter_map do |pattern|
names.find { |name| name.match?(pattern) }
end.first
UI.user_error!("No available #{family.fetch(:label)} simulator found for App Store screenshots.") if match.nil?
match
end
end
def watch_snapshot_device
raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip
raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw
end
def available_simulator_devices
stdout, stderr, status = Open3.capture3("xcrun", "simctl", "list", "devices", "available", "--json")
unless status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Failed to list simulator devices: #{detail}")
end
JSON.parse(stdout).fetch("devices").values.flatten
rescue JSON::ParserError => e
UI.user_error!("Invalid JSON from simctl device list: #{e.message}")
end
def resolve_simulator_device(name)
devices = available_simulator_devices
exact = devices.find { |device| device["name"] == name }
return exact if exact
watch_devices = devices.select { |device| device["name"].to_s.include?("Apple Watch") }
fallback = watch_devices.find { |device| device["name"].to_s.include?("Ultra") } || watch_devices.first
UI.user_error!("No available Apple Watch simulators found.") unless fallback
UI.important("Apple Watch simulator '#{name}' was not found; using '#{fallback.fetch("name")}'.")
fallback
end
def install_ready_for_review_edit_state_lookup!
require "spaceship"
app_class = Spaceship::ConnectAPI::App
app_class.class_eval do
unless method_defined?(:openclaw_get_edit_app_store_version_without_ready_for_review)
alias_method :openclaw_get_edit_app_store_version_without_ready_for_review, :get_edit_app_store_version
end
unless method_defined?(:openclaw_fetch_edit_app_info_without_ready_for_review)
alias_method :openclaw_fetch_edit_app_info_without_ready_for_review, :fetch_edit_app_info
end
def get_edit_app_store_version(client: nil, platform: nil, includes: Spaceship::ConnectAPI::AppStoreVersion::ESSENTIAL_INCLUDES)
version = openclaw_get_edit_app_store_version_without_ready_for_review(client: client, platform: platform, includes: includes)
return version if version
# First public releases can leave the only version in READY_FOR_REVIEW.
# Fastlane 2.236.1 excludes that state and then tries to create an illegal
# second version; use the existing review-ready version as the edit target.
client ||= Spaceship::ConnectAPI
platform ||= Spaceship::ConnectAPI::Platform::IOS
filter = {
appVersionState: Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_REVIEW,
platform: platform
}
get_app_store_versions(client: client, filter: filter, includes: includes)
.sort_by { |candidate| Gem::Version.new(candidate.version_string) }
.last
end
def fetch_edit_app_info(client: nil, includes: Spaceship::ConnectAPI::AppInfo::ESSENTIAL_INCLUDES)
app_info = openclaw_fetch_edit_app_info_without_ready_for_review(client: client, includes: includes)
return app_info if app_info
client ||= Spaceship::ConnectAPI
client
.get_app_infos(app_id: id, includes: includes)
.to_models
.find { |candidate| candidate.state == Spaceship::ConnectAPI::AppInfo::State::READY_FOR_REVIEW }
end
end
end
def bundle_identifier_for_product(product_path)
info_plist_path = File.join(product_path, "Info.plist")
UI.user_error!("Expected Info.plist at #{info_plist_path}.") unless File.exist?(info_plist_path)
stdout, stderr, status = Open3.capture3(
"/usr/libexec/PlistBuddy",
"-c",
"Print:CFBundleIdentifier",
info_plist_path
)
unless status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Failed to read bundle identifier from #{info_plist_path}: #{detail}")
end
bundle_identifier = stdout.to_s.strip
UI.user_error!("Missing bundle identifier in #{info_plist_path}.") if bundle_identifier.empty?
bundle_identifier
end
def write_watch_screenshot_mode_defaults(udid, bundle_identifiers)
bundle_identifiers.each do |bundle_identifier|
sh(
shell_join([
"xcrun",
"simctl",
"spawn",
udid,
"defaults",
"write",
bundle_identifier,
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY,
"-bool",
"YES",
])
)
end
end
def clear_watch_screenshot_mode_defaults(udid, bundle_identifiers)
bundle_identifiers.each do |bundle_identifier|
sh(
"#{shell_join([
"xcrun",
"simctl",
"spawn",
udid,
"defaults",
"delete",
bundle_identifier,
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY,
])} >/dev/null 2>&1 || true"
)
end
end
def status_bar_unsupported?(detail)
detail.include?("Status bar overrides not supported on this platform") ||
detail.include?("Operation not supported")
end
def set_watch_status_bar_override(udid)
stdout, stderr, status = Open3.capture3(
"xcrun",
"simctl",
"status_bar",
udid,
"override",
"--time",
WATCH_SNAPSHOT_STATUS_BAR_TIME
)
return true if status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
if status_bar_unsupported?(detail)
UI.important("watchOS simulator status bar overrides are not supported; Watch screenshot clock will use simulator time.")
return false
end
UI.user_error!("Failed to override Watch simulator status bar: #{detail}")
end
def clear_watch_status_bar_override(udid)
stdout, stderr, status = Open3.capture3("xcrun", "simctl", "status_bar", udid, "clear")
return if status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Failed to clear Watch simulator status bar override: #{detail}") unless status_bar_unsupported?(detail)
end
def normalize_watch_screenshot_status_bar(path)
script = <<~SWIFT
import AppKit
import Foundation
import ImageIO
let path = CommandLine.arguments[1]
let timeText = CommandLine.arguments[2]
guard let source = NSImage(contentsOfFile: path),
let cgImage = source.cgImage(forProposedRect: nil, context: nil, hints: nil)
else {
fputs("Failed to load screenshot at \\(path)\\n", stderr)
exit(2)
}
let width = cgImage.width
let height = cgImage.height
let drawWidth = CGFloat(width)
let drawHeight = CGFloat(height)
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let bitmapContext = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
else {
fputs("Failed to create normalized screenshot bitmap at \\(path)\\n", stderr)
exit(3)
}
let graphicsContext = NSGraphicsContext(cgContext: bitmapContext, flipped: false)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = graphicsContext
NSColor.black.setFill()
NSBezierPath(rect: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight)).fill()
source.draw(
in: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
from: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
operation: .sourceOver,
fraction: 1.0)
NSColor.black.setFill()
NSBezierPath(rect: NSRect(x: drawWidth - 146, y: drawHeight - 92, width: 124, height: 70)).fill()
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .right
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedDigitSystemFont(ofSize: 34, weight: .semibold),
.foregroundColor: NSColor.white,
.paragraphStyle: paragraphStyle,
]
timeText.draw(
in: NSRect(x: drawWidth - 134, y: drawHeight - 82, width: 102, height: 44),
withAttributes: attributes)
NSGraphicsContext.restoreGraphicsState()
guard let output = bitmapContext.makeImage(),
let destination = CGImageDestinationCreateWithURL(
URL(fileURLWithPath: path) as CFURL,
"public.png" as CFString,
1,
nil)
else {
fputs("Failed to encode normalized screenshot at \\(path)\\n", stderr)
exit(4)
}
CGImageDestinationAddImage(destination, output, nil)
guard CGImageDestinationFinalize(destination) else {
fputs("Failed to write normalized screenshot at \\(path)\\n", stderr)
exit(5)
}
SWIFT
Tempfile.create(["openclaw-watch-status-bar", ".swift"]) do |file|
file.write(script)
file.flush
sh(shell_join(["xcrun", "swift", file.path, path, "9:41"]))
end
end
def capture_watch_screenshot
device = resolve_simulator_device(watch_snapshot_device)
device_name = device.fetch("name")
udid = device.fetch("udid")
output_dir = File.join(ios_root, "fastlane", "screenshots", "en-US")
output_path = File.join(output_dir, "#{device_name}-01-now-face.png")
derived_data_path = File.join(ios_root, "build", "WatchScreenshotDerivedData")
app_path = File.join(derived_data_path, "Build", "Products", "Debug-watchsimulator", "OpenClawWatchApp.app")
FileUtils.mkdir_p(output_dir)
Dir[File.join(output_dir, "Apple Watch*-*.png")].each { |path| FileUtils.rm_f(path) }
FileUtils.rm_rf(derived_data_path)
# Single-target watch apps only expose generic simulator build destinations in Xcode.
# Keep the selected UDID for install/launch/screenshot below.
sh(
xcodebuild_shell_join([
"xcodebuild",
"-project",
File.join(ios_root, "OpenClaw.xcodeproj"),
"-scheme",
"OpenClawWatchApp",
"-configuration",
"Debug",
"-destination",
"generic/platform=watchOS Simulator",
"-derivedDataPath",
derived_data_path,
"build",
])
)
UI.user_error!("Watch screenshot build did not produce #{app_path}.") unless File.exist?(app_path)
watch_app_identifier = bundle_identifier_for_product(app_path)
screenshot_mode_bundle_identifiers = [watch_app_identifier]
sh("#{shell_join(["xcrun", "simctl", "boot", udid])} >/dev/null 2>&1 || true")
sh(shell_join(["xcrun", "simctl", "bootstatus", udid, "-b"]))
sh("#{shell_join(["xcrun", "simctl", "uninstall", udid, watch_app_identifier])} >/dev/null 2>&1 || true")
status_bar_overridden = false
begin
sh(shell_join(["xcrun", "simctl", "install", udid, app_path]))
write_watch_screenshot_mode_defaults(udid, screenshot_mode_bundle_identifiers)
status_bar_overridden = set_watch_status_bar_override(udid)
sh(
"SIMCTL_CHILD_OPENCLAW_WATCH_SCREENSHOT_MODE=1 #{shell_join([
"xcrun",
"simctl",
"launch",
udid,
watch_app_identifier,
"--openclaw-watch-screenshot-mode",
])}"
)
sleep(3)
sh(shell_join(["xcrun", "simctl", "io", udid, "screenshot", output_path]))
normalize_watch_screenshot_status_bar(output_path)
ensure
clear_watch_status_bar_override(udid) if status_bar_overridden
clear_watch_screenshot_mode_defaults(udid, screenshot_mode_bundle_identifiers)
end
UI.success("Captured Apple Watch screenshot: #{output_path}")
output_path
end
def maybe_decode_hex_keychain_secret(value)
return value unless env_present?(value)
candidate = value.strip
return candidate unless candidate.match?(/\A[0-9a-fA-F]+\z/) && candidate.length.even?
begin
decoded = [candidate].pack("H*")
return candidate unless decoded.valid_encoding?
# `security find-generic-password -w` can return hex when the stored secret
# includes newlines/non-printable bytes (like PEM files).
beginPemMarker = %w[BEGIN PRIVATE KEY].join(" ") # pragma: allowlist secret
endPemMarker = %w[END PRIVATE KEY].join(" ")
if decoded.include?(beginPemMarker) || decoded.include?(endPemMarker)
UI.message("Decoded hex-encoded App Store Connect key content from Keychain.")
return decoded
end
rescue StandardError
return candidate
end
candidate
end
def read_app_store_connect_key_content_from_keychain
service = ENV["APP_STORE_CONNECT_KEYCHAIN_SERVICE"]
service = DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE unless env_present?(service)
account = ENV["APP_STORE_CONNECT_KEYCHAIN_ACCOUNT"]
account = ENV["USER"] unless env_present?(account)
account = ENV["LOGNAME"] unless env_present?(account)
return nil unless env_present?(account)
begin
stdout, _stderr, status = Open3.capture3(
"security",
"find-generic-password",
"-s",
service,
"-a",
account,
"-w"
)
return nil unless status.success?
key_content = stdout.to_s.strip
key_content = maybe_decode_hex_keychain_secret(key_content)
return nil unless env_present?(key_content)
UI.message("Loaded App Store Connect key content from Keychain service '#{service}' (account '#{account}').")
key_content
rescue Errno::ENOENT
nil
end
end
def repo_root
File.expand_path("../../..", __dir__)
end
def ios_root
File.expand_path("..", __dir__)
end
def preserve_file(path)
existed = File.exist?(path)
contents = existed ? File.binread(path) : nil
yield
ensure
if existed
File.binwrite(path, contents)
else
FileUtils.rm_f(path)
end
end
def preserve_local_signing
preserve_file(File.join(ios_root, ".local-signing.xcconfig")) do
yield
end
end
def app_store_signing_manifest
JSON.parse(File.read(File.join(ios_root, "Config", "AppStoreSigning.json")))
end
def app_store_signing_targets
app_store_signing_manifest.fetch("targets")
end
def app_store_bundle_identifiers
app_store_signing_targets.map { |target| target.fetch("bundleId") }
end
def app_store_provisioning_profiles
app_store_signing_targets.each_with_object({}) do |target, profiles|
profiles[target.fetch("bundleId")] = target.fetch("profileName")
end
end
def xml_string(value)
CGI.escapeHTML(value.to_s)
end
def write_app_store_export_options(path)
manifest = app_store_signing_manifest
profile_entries = app_store_provisioning_profiles.map do |bundle_id, profile_name|
" <key>#{xml_string(bundle_id)}</key>\n <string>#{xml_string(profile_name)}</string>"
end.join("\n")
File.write(path, <<~PLIST)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>teamID</key>
<string>#{xml_string(manifest.fetch("teamId"))}</string>
<key>provisioningProfiles</key>
<dict>
#{profile_entries}
</dict>
<key>destination</key>
<string>export</string>
<key>stripSwiftSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
PLIST
end
def produce_services_for_target(target)
services = {}
if target.fetch("capabilities").include?("PUSH_NOTIFICATIONS")
services[:push_notification] = "on"
end
if target.fetch("capabilities").include?("APP_GROUPS")
services[:app_group] = "on"
end
if target.fetch("capabilities").include?("APP_ATTEST")
services[:app_attest] = "on"
end
services
end
def ensure_release_bundle_ids!
manifest = app_store_signing_manifest
app_store_signing_targets.each do |target|
options = {
app_identifier: target.fetch("bundleId"),
app_name: target.fetch("displayName"),
skip_itc: true,
team_id: manifest.fetch("teamId")
}
services = produce_services_for_target(target)
options[:enable_services] = services unless services.empty?
produce(**options)
unless services.empty?
modify_services(
app_identifier: target.fetch("bundleId"),
services: services,
team_id: manifest.fetch("teamId")
)
end
end
end
def app_store_match_options(readonly:, target:, api_key:)
manifest = app_store_signing_manifest
options = {
type: manifest.fetch("profileType"),
app_identifier: target.fetch("bundleId"),
profile_name: target.fetch("profileName"),
git_url: manifest.fetch("signingRepo"),
git_branch: manifest.fetch("signingBranch"),
platform: "ios",
team_id: manifest.fetch("teamId"),
readonly: readonly
}
options[:api_key] = api_key if api_key
options
end
def validate_match_profile_mapping!(target)
bundle_id = target.fetch("bundleId")
expected_profile_name = target.fetch("profileName")
actual = lane_context[SharedValues::MATCH_PROVISIONING_PROFILE_MAPPING] || {}
actual_profile_name = actual[bundle_id]
return if actual_profile_name == expected_profile_name
UI.user_error!(
"Fastlane match did not resolve the pinned App Store profile for #{bundle_id}: expected #{expected_profile_name}, got #{actual_profile_name || "no match output"}"
)
end
def match_profile_env_key(target, suffix)
["sigh", target.fetch("bundleId"), app_store_signing_manifest.fetch("profileType"), suffix].join("_")
end
def profile_plist_value(profile_path, key_path)
Tempfile.create(["openclaw-profile", ".plist"]) do |file|
stdout, stderr, status = Open3.capture3("security", "cms", "-D", "-i", profile_path)
unless status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Failed to decode provisioning profile #{profile_path}: #{detail}")
end
file.write(stdout)
file.flush
value, _plist_stderr, plist_status = Open3.capture3("/usr/libexec/PlistBuddy", "-c", "Print:#{key_path}", file.path)
return nil unless plist_status.success?
value.to_s.strip
end
end
def profile_plist_array_values(profile_path, key_path)
raw = profile_plist_value(profile_path, key_path)
return [] unless raw
raw.lines.map(&:strip).reject do |line|
line.empty? || line == "Array {" || line == "}"
end
end
def validate_match_profile_capabilities!(target)
capabilities = target.fetch("capabilities")
return if capabilities.empty?
profile_path = ENV[match_profile_env_key(target, "profile-path")]
UI.user_error!("Fastlane match did not expose an installed profile path for #{target.fetch("bundleId")}.") unless env_present?(profile_path)
if capabilities.include?("PUSH_NOTIFICATIONS")
aps_environment = profile_plist_value(profile_path, "Entitlements:aps-environment")
if aps_environment != "production"
UI.user_error!(
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing production push entitlement; expected aps-environment=production, got #{aps_environment || "missing"}."
)
end
end
if capabilities.include?("APP_GROUPS")
expected_app_groups = target.fetch("appGroups")
actual_app_groups = profile_plist_array_values(profile_path, "Entitlements:com.apple.security.application-groups")
missing = expected_app_groups - actual_app_groups
unless missing.empty?
UI.user_error!(
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing App Groups #{missing.join(", ")}; actual groups: #{actual_app_groups.empty? ? "missing" : actual_app_groups.join(", ")}."
)
end
end
if capabilities.include?("APP_ATTEST")
app_attest_environments = profile_plist_array_values(profile_path, "Entitlements:com.apple.developer.devicecheck.appattest-environment")
unless app_attest_environments.include?("production")
UI.user_error!(
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing production App Attest entitlement; actual environments: #{app_attest_environments.empty? ? "missing" : app_attest_environments.join(", ")}."
)
end
end
end
def sync_app_store_signing!(readonly:)
api_key = readonly ? nil : app_store_connect_api_key_config
app_store_signing_targets.each do |target|
match(**app_store_match_options(readonly: readonly, target: target, api_key: api_key))
validate_match_profile_mapping!(target)
validate_match_profile_capabilities!(target)
end
end
def release_signing_check!
sync_app_store_signing!(readonly: true)
end
def release_notes_path
File.join(__dir__, "metadata", "en-US", "release_notes.txt")
end
def release_notes_metadata_path
source = release_notes_path
UI.user_error!("Missing release notes at #{source}. Run `pnpm ios:version:sync`.") unless File.exist?(source)
temp_root = Dir.mktmpdir("openclaw-release-notes")
target_dir = File.join(temp_root, "en-US")
FileUtils.mkdir_p(target_dir)
FileUtils.cp(source, File.join(target_dir, "release_notes.txt"))
temp_root
end
def app_review_notes_markdown_path
File.join(ios_root, "APP-REVIEW-NOTES.md")
end
def app_review_notes_pdf_path
File.join(ios_root, "build", "app-review", "APP-REVIEW-NOTES.pdf")
end
def generate_app_review_notes_pdf!
source = app_review_notes_markdown_path
UI.user_error!("Missing App Review notes at #{source}.") unless File.exist?(source)
output = app_review_notes_pdf_path
FileUtils.mkdir_p(File.dirname(output))
sh(shell_join(["xcrun", "swift", File.join(repo_root, "scripts", "ios-app-review-notes-pdf.swift"), source, output]))
output
end
def assert_no_app_review_notes_field_metadata!(metadata_path)
notes_dir = File.join(metadata_path, "review_information")
APP_REVIEW_NOTES_METADATA_FILENAMES.each do |filename|
path = File.join(notes_dir, filename)
next unless File.exist?(path)
UI.user_error!(
"Refusing to upload App Review Notes metadata from #{path}. " \
"Maintain the App Store Connect Notes field manually so the live setup code is not stored in this repo."
)
end
end
def public_metadata_path
source = File.join(__dir__, "metadata")
temp_root = Dir.mktmpdir("openclaw-app-store-metadata")
Dir.children(source).each do |entry|
source_entry = File.join(source, entry)
next unless File.directory?(source_entry)
next unless PUBLIC_METADATA_FILENAMES.any? { |filename| File.exist?(File.join(source_entry, filename)) }
FileUtils.cp_r(source_entry, File.join(temp_root, entry))
end
temp_root
end
def app_store_screenshot_root
File.join(__dir__, "screenshots")
end
def app_store_screenshot_manifest
require "deliver/loader"
Deliver::Loader.load_app_screenshots(app_store_screenshot_root, false)
end
def resolve_app_store_connect_app(app_identifier:, app_id:)
require "spaceship"
app = if env_present?(app_id) && !env_present?(app_identifier)
Spaceship::ConnectAPI::App.get(app_id: app_id)
else
Spaceship::ConnectAPI::App.find(app_identifier || APP_STORE_APP_IDENTIFIER)
end
UI.user_error!("Could not find App Store Connect app #{app_identifier || app_id || APP_STORE_APP_IDENTIFIER}.") unless app
app
end
def resolve_app_store_connect_version(app:, short_version:)
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::IOS)
UI.user_error!("Could not find an editable App Store Connect version for #{app.name}.") unless version
if version.version_string != short_version
UI.user_error!(
"Editable App Store Connect version mismatch for #{app.name}: expected #{short_version}, got #{version.version_string}."
)
end
version
end
def app_store_screenshot_sets_for_display_type(localization:, display_type:)
localization
.get_app_screenshot_sets(includes: "appScreenshots")
.select { |set| set.screenshot_display_type == display_type }
end
def clear_app_store_screenshot_sets!(localization:)
existing_sets = localization.get_app_screenshot_sets(includes: "appScreenshots")
return if existing_sets.empty?
existing_sets.each do |set|
UI.message("Deleting existing #{localization.locale} #{set.screenshot_display_type} screenshot set #{set.id}.")
set.delete!
end
deadline = Time.now + APP_STORE_SCREENSHOT_SET_DELETE_TIMEOUT_SECONDS
loop do
sets = localization.get_app_screenshot_sets(includes: "appScreenshots")
return if sets.empty?
if Time.now >= deadline
UI.user_error!(
"Timed out waiting for App Store Connect to delete #{localization.locale} screenshot sets: #{sets.map(&:id).join(', ')}."
)
end
sleep(3)
end
end
def app_store_screenshot_expected_rows(screenshots)
screenshots.map do |screenshot|
{
checksum: Digest::MD5.file(screenshot.path).hexdigest,
file_name: File.basename(screenshot.path)
}
end
end
def app_store_screenshot_actual_rows(app_screenshot_set)
(app_screenshot_set.app_screenshots || []).map do |screenshot|
{
checksum: screenshot.source_file_checksum,
file_name: screenshot.file_name,
state: (screenshot.asset_delivery_state || {})["state"]
}
end
end
def format_app_store_screenshot_rows(rows)
rows.map do |row|
[row[:file_name], row[:checksum], row[:state]].compact.join(" ")
end.join(", ")
end
def app_store_screenshot_processing_timeout_seconds
raw = ENV["DELIVER_SCREENSHOT_PROCESSING_TIMEOUT"].to_s.strip
return APP_STORE_SCREENSHOT_PROCESSING_TIMEOUT_SECONDS if raw.empty?
unless raw.match?(/\A\d+\z/) && raw.to_i.positive?
UI.user_error!("Invalid DELIVER_SCREENSHOT_PROCESSING_TIMEOUT '#{raw}'. Expected a positive number of seconds.")
end
raw.to_i
end
def app_store_screenshot_state_counts(screenshots)
screenshots.each_with_object({}) do |screenshot, counts|
state = (screenshot.asset_delivery_state || {})["state"] || "UNKNOWN"
counts[state] ||= 0
counts[state] += 1
end
end
def wait_for_app_store_screenshots_processing!(screenshot_ids:, locale:, display_type:)
timeout_seconds = app_store_screenshot_processing_timeout_seconds
deadline = Time.now + timeout_seconds
loop do
screenshots = screenshot_ids.map do |screenshot_id|
Spaceship::ConnectAPI.get_app_screenshot(app_screenshot_id: screenshot_id).first
end
failed = screenshots.select(&:error?)
unless failed.empty?
details = failed.map { |screenshot| "#{screenshot.file_name}: #{screenshot.error_messages.join(', ')}" }
UI.user_error!("App Store Connect failed processing #{locale} #{display_type} screenshots: #{details.join('; ')}.")
end
return screenshots if screenshots.all?(&:complete?)
if Time.now >= deadline
states = app_store_screenshot_state_counts(screenshots)
UI.user_error!(
"Timed out after #{timeout_seconds}s waiting for App Store Connect to process #{locale} #{display_type} screenshots: #{states}."
)
end
UI.verbose("Waiting for #{locale} #{display_type} screenshots to finish processing: #{app_store_screenshot_state_counts(screenshots)}.")
sleep(APP_STORE_SCREENSHOT_PROCESSING_POLL_SECONDS)
end
end
def validate_app_store_screenshot_target_counts!(screenshots_by_target)
screenshots_by_target.each do |(locale, display_type), screenshots|
next if screenshots.length <= APP_STORE_SCREENSHOT_LIMIT_PER_SET
UI.user_error!(
"Found #{screenshots.length} screenshots for #{locale} #{display_type}; App Store Connect allows #{APP_STORE_SCREENSHOT_LIMIT_PER_SET}."
)
end
end
def verify_app_store_screenshot_set!(app_screenshot_set:, screenshots:, locale:, display_type:)
expected = app_store_screenshot_expected_rows(screenshots)
timeout_seconds = app_store_screenshot_processing_timeout_seconds
deadline = Time.now + timeout_seconds
actual = []
loop do
app_screenshot_set = Spaceship::ConnectAPI::AppScreenshotSet.get(app_screenshot_set_id: app_screenshot_set.id)
actual = app_store_screenshot_actual_rows(app_screenshot_set)
actual_identity = actual.map { |row| { checksum: row[:checksum], file_name: row[:file_name] } }
incomplete = actual.reject { |row| row[:state] == "COMPLETE" }
return if actual_identity == expected && incomplete.empty?
if actual.length > expected.length
UI.user_error!(
"App Store Connect screenshot verification failed for #{locale} #{display_type}. " \
"Expected: #{format_app_store_screenshot_rows(expected)}. " \
"Actual: #{format_app_store_screenshot_rows(actual)}."
)
end
if Time.now >= deadline
UI.user_error!(
"Timed out after #{timeout_seconds}s waiting for App Store Connect screenshot verification for #{locale} #{display_type}. " \
"Expected: #{format_app_store_screenshot_rows(expected)}. " \
"Actual: #{format_app_store_screenshot_rows(actual)}."
)
end
UI.verbose(
"Waiting for App Store Connect screenshot verification for #{locale} #{display_type}: " \
"#{format_app_store_screenshot_rows(actual)}."
)
sleep(APP_STORE_SCREENSHOT_PROCESSING_POLL_SECONDS)
end
end
def replace_app_store_screenshot_set!(localization:, display_type:, screenshots:)
existing_sets = app_store_screenshot_sets_for_display_type(localization: localization, display_type: display_type)
unless existing_sets.empty?
UI.user_error!(
"App Store Connect still has #{localization.locale} #{display_type} screenshot sets after reset: #{existing_sets.map(&:id).join(', ')}."
)
end
UI.message("Creating #{localization.locale} #{display_type} screenshot set.")
app_screenshot_set = localization.create_app_screenshot_set(attributes: { screenshotDisplayType: display_type })
uploaded_ids = screenshots.map.with_index do |screenshot, index|
started_at = Time.now
uploaded = app_screenshot_set.upload_screenshot(path: screenshot.path, wait_for_processing: false)
UI.message(
"Uploaded #{localization.locale} #{display_type} screenshot #{index + 1}/#{screenshots.length}: " \
"#{File.basename(screenshot.path)} (#{(Time.now - started_at).round(1)}s)."
)
uploaded.id
end
wait_for_app_store_screenshots_processing!(
screenshot_ids: uploaded_ids,
locale: localization.locale,
display_type: display_type
)
app_screenshot_set = Spaceship::ConnectAPI::AppScreenshotSet.get(app_screenshot_set_id: app_screenshot_set.id)
app_screenshot_set = app_screenshot_set.reorder_screenshots(app_screenshot_ids: uploaded_ids)
verify_app_store_screenshot_set!(
app_screenshot_set: app_screenshot_set,
screenshots: screenshots,
locale: localization.locale,
display_type: display_type
)
end
# Fastlane deliver can duplicate complete screenshots when its verification retry
# runs before App Store Connect consistently lists processed assets. Keep the
# screenshot write path serial and assert the remote set equals the local files.
def upload_app_store_screenshots_deterministically!(app_identifier:, app_id:, short_version:, screenshots:)
app = resolve_app_store_connect_app(app_identifier: app_identifier, app_id: app_id)
version = resolve_app_store_connect_version(app: app, short_version: short_version)
localizations_by_locale = version.get_app_store_version_localizations.each_with_object({}) do |localization, index|
index[localization.locale] = localization
end
screenshots_by_target = screenshots
.sort_by { |screenshot| [screenshot.language.to_s, screenshot.display_type.to_s, File.basename(screenshot.path)] }
.group_by { |screenshot| [screenshot.language, screenshot.display_type] }
validate_app_store_screenshot_target_counts!(screenshots_by_target)
missing_locales = screenshots_by_target.keys.map(&:first).uniq.reject { |locale| localizations_by_locale.key?(locale) }
unless missing_locales.empty?
UI.user_error!(
"App Store Connect localizations are missing for screenshot locales #{missing_locales.join(', ')}. " \
"Upload metadata for these locales before uploading screenshots."
)
end
screenshots_by_target.keys.map(&:first).uniq.each do |locale|
clear_app_store_screenshot_sets!(localization: localizations_by_locale.fetch(locale))
end
screenshots_by_target.each do |(locale, display_type), target_screenshots|
replace_app_store_screenshot_set!(
localization: localizations_by_locale.fetch(locale),
display_type: display_type,
screenshots: target_screenshots
)
end
UI.success("Uploaded and verified #{screenshots.length} App Store screenshots for #{short_version}.")
end
def read_ios_version_metadata
script_path = File.join(repo_root, "scripts", "ios-version.ts")
stdout, stderr, status = Open3.capture3(
"node",
"--import",
"tsx",
script_path,
"--json",
chdir: repo_root
)
unless status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Failed to read iOS version metadata: #{detail}")
end
parsed = JSON.parse(stdout)
version = parsed["canonicalVersion"].to_s.strip
short_version = parsed["marketingVersion"].to_s.strip
if !env_present?(version) || !env_present?(short_version)
UI.user_error!("iOS version helper returned incomplete metadata.")
end
{
short_version: short_version,
version: version
}
rescue JSON::ParserError => e
UI.user_error!("Invalid JSON from iOS version helper: #{e.message}")
end
def sync_ios_versioning!
script_path = File.join(repo_root, "scripts", "ios-sync-versioning.ts")
stdout, stderr, status = Open3.capture3(
"node",
"--import",
"tsx",
script_path,
"--check",
chdir: repo_root
)
return if status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("iOS versioning artifacts are stale. Run `pnpm ios:version:sync`.\n#{detail}")
end
def shell_join(parts)
Shellwords.join(parts.compact)
end
def xcodebuild_shell_join(parts)
xcode_path = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin"
shell_join(["env", "PATH=#{xcode_path}", *parts])
end
def resolve_release_build_number(api_key:, short_version:)
explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
if env_present?(explicit)
UI.user_error!("Invalid iOS release build number '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
UI.message("Using explicit iOS release build number #{explicit}.")
return explicit
end
latest_build = latest_testflight_build_number(
api_key: api_key,
app_identifier: APP_STORE_APP_IDENTIFIER,
version: short_version,
initial_build_number: 0
)
next_build = latest_build.to_i + 1
UI.message("Resolved iOS release build number #{next_build} for #{short_version} (latest App Store Connect build: #{latest_build}).")
next_build.to_s
end
def release_build_number_needs_app_store_connect_auth?
explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
!env_present?(explicit)
end
def prepare_app_store_release!(version:, build_number:)
script_path = File.join(repo_root, "scripts", "ios-release-prepare.sh")
UI.message("Preparing iOS App Store release #{version} (build #{build_number}).")
sh(shell_join(["bash", script_path, "--build-number", build_number]))
release_xcconfig = File.join(ios_root, "build", "AppStoreRelease.xcconfig")
UI.user_error!("Missing App Store release xcconfig at #{release_xcconfig}.") unless File.exist?(release_xcconfig)
ENV["XCODE_XCCONFIG_FILE"] = release_xcconfig
release_xcconfig
end
def validate_app_store_ipa!(ipa_path)
script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh")
sh(shell_join(["bash", script_path, "--ipa", ipa_path]))
end
def build_app_store_release(context)
version = context[:version]
project_path = File.join(ios_root, "OpenClaw.xcodeproj")
output_directory = File.join(ios_root, "build", "app-store")
archive_path = File.join(output_directory, "OpenClaw-#{version}.xcarchive")
export_options_path = File.join(output_directory, "ExportOptions.plist")
output_name = "OpenClaw-#{version}.ipa"
expected_ipa_path = File.join(output_directory, output_name)
FileUtils.mkdir_p(output_directory)
FileUtils.rm_rf(archive_path)
Dir[File.join(output_directory, "*.ipa")].each { |path| FileUtils.rm_f(path) }
write_app_store_export_options(export_options_path)
sh(
xcodebuild_shell_join([
"xcodebuild",
"-project",
project_path,
"-scheme",
"OpenClaw",
"-configuration",
"Release",
"-destination",
"generic/platform=iOS",
"-archivePath",
archive_path,
"clean",
"archive",
])
)
sh(
xcodebuild_shell_join([
"xcodebuild",
"-exportArchive",
"-archivePath",
archive_path,
"-exportPath",
output_directory,
"-exportOptionsPlist",
export_options_path,
])
)
exported_ipas = Dir[File.join(output_directory, "*.ipa")]
UI.user_error!("xcodebuild export did not produce an IPA in #{output_directory}.") if exported_ipas.empty?
UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1
exported_ipa = exported_ipas.first
FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path
validate_app_store_ipa!(expected_ipa_path)
{
archive_path: archive_path,
build_number: context[:build_number],
ipa_path: expected_ipa_path,
short_version: context[:short_version],
version: version
}
end
def app_store_connect_api_key_config
load_env_file(File.join(__dir__, ".env"))
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
clear_empty_env_var("APP_STORE_CONNECT_KEY_PATH")
clear_empty_env_var("APP_STORE_CONNECT_KEY_CONTENT")
api_key = nil
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
if env_present?(key_path)
api_key = app_store_connect_api_key(path: key_path)
else
p8_path = ENV["APP_STORE_CONNECT_KEY_PATH"]
if env_present?(p8_path)
key_id = ENV["APP_STORE_CONNECT_KEY_ID"]
issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"]
UI.user_error!("Missing APP_STORE_CONNECT_KEY_ID or APP_STORE_CONNECT_ISSUER_ID for APP_STORE_CONNECT_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| !env_present?(v) }
api_key = app_store_connect_api_key(
key_id: key_id,
issuer_id: issuer_id,
key_filepath: p8_path
)
else
key_id = ENV["APP_STORE_CONNECT_KEY_ID"]
issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"]
key_content = ENV["APP_STORE_CONNECT_KEY_CONTENT"]
key_content = read_app_store_connect_key_content_from_keychain unless env_present?(key_content)
UI.user_error!(
"Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json), APP_STORE_CONNECT_KEY_PATH (p8), or APP_STORE_CONNECT_KEY_ID/APP_STORE_CONNECT_ISSUER_ID with APP_STORE_CONNECT_KEY_CONTENT (or Keychain via APP_STORE_CONNECT_KEYCHAIN_SERVICE/APP_STORE_CONNECT_KEYCHAIN_ACCOUNT)."
) if [key_id, issuer_id, key_content].any? { |v| !env_present?(v) }
is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true
api_key = app_store_connect_api_key(
key_id: key_id,
issuer_id: issuer_id,
key_content: key_content,
is_key_content_base64: is_base64
)
end
end
api_key
end
platform :ios do
private_lane :prepare_app_store_context do |options|
require_api_key = options[:require_api_key] == true
needs_api_key = require_api_key || release_build_number_needs_app_store_connect_auth?
api_key = needs_api_key ? app_store_connect_api_key_config : nil
sync_ios_versioning!
version_metadata = read_ios_version_metadata
version = version_metadata[:version]
short_version = version_metadata[:short_version]
build_number = resolve_release_build_number(api_key: api_key, short_version: short_version)
release_xcconfig = prepare_app_store_release!(version: version, build_number: build_number)
{
api_key: api_key,
build_number: build_number,
release_xcconfig: release_xcconfig,
short_version: short_version,
version: version
}
end
desc "Print the App Store signing plan"
lane :signing_plan do
sh(shell_join(["node", File.join(repo_root, "scripts", "ios-release-signing.mjs"), "--mode", "plan"]))
end
desc "Check local App Store signing assets through Fastlane match"
lane :signing_check do
sync_app_store_signing!(readonly: true)
UI.success("Fastlane match App Store signing assets are available locally.")
end
desc "Create Developer Portal bundle IDs/services and sync App Store signing assets"
lane :signing_setup do
ensure_release_bundle_ids!
sync_app_store_signing!(readonly: false)
UI.success("Fastlane App Store signing setup is complete.")
end
desc "Pull encrypted App Store signing assets from the shared Fastlane match repo"
lane :signing_sync_pull do
sync_app_store_signing!(readonly: true)
UI.success("Pulled Fastlane match App Store signing assets.")
end
desc "Create or refresh encrypted App Store signing assets in the shared Fastlane match repo"
lane :signing_sync_push do
ensure_release_bundle_ids!
sync_app_store_signing!(readonly: false)
UI.success("Pushed Fastlane match App Store signing assets.")
end
desc "Build an App Store distribution archive locally without uploading"
lane :app_store_archive do
context = prepare_app_store_context(require_api_key: false)
build = build_app_store_release(context)
UI.success("Built iOS App Store archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
build
ensure
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Generate screenshots, update App Store metadata and review attachment, then upload an App Store build"
lane :release_upload do
unless ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
end
release_signing_check!
preserve_local_signing do
screenshots
end
ENV["DELIVER_SCREENSHOTS"] = "1"
ENV["DELIVER_RELEASE_NOTES"] = "1"
metadata
context = prepare_app_store_context(require_api_key: true)
build = build_app_store_release(context)
upload_to_testflight(
api_key: context[:api_key],
ipa: build[:ipa_path],
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
UI.important("App Review submission remains manual in App Store Connect.")
ensure
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Upload App Store metadata, App Review PDF attachment, and optionally screenshots"
lane :metadata do
install_ready_for_review_edit_state_lookup!
sync_ios_versioning!
version_metadata = read_ios_version_metadata
api_key = app_store_connect_api_key_config
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
app_identifier = ENV["APP_STORE_CONNECT_APP_IDENTIFIER"]
app_id = ENV["APP_STORE_CONNECT_APP_ID"]
app_identifier = nil unless env_present?(app_identifier)
app_id = nil unless env_present?(app_id)
if screenshot_upload_requested?
screenshots_to_upload = app_store_screenshot_manifest
if screenshots_to_upload.empty?
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
end
validate_required_screenshots!(screenshots_to_upload.map(&:path))
end
assert_no_app_review_notes_field_metadata!(File.join(__dir__, "metadata"))
metadata_path = public_metadata_path
skip_metadata = ENV["DELIVER_METADATA"] != "1"
if release_notes_upload_requested? && skip_metadata
metadata_path = release_notes_metadata_path
skip_metadata = false
end
assert_no_app_review_notes_field_metadata!(metadata_path) unless skip_metadata
app_review_attachment_file = skip_metadata ? nil : generate_app_review_notes_pdf!
deliver_options = {
api_key: api_key,
force: true,
app_version: version_metadata[:short_version],
copyright: "2026 OpenClaw",
primary_category: "PRODUCTIVITY",
secondary_category: "UTILITIES",
metadata_path: metadata_path,
skip_screenshots: true,
skip_metadata: skip_metadata,
skip_binary_upload: true,
overwrite_screenshots: false,
app_review_attachment_file: app_review_attachment_file,
skip_app_version_update: false,
submit_for_review: false,
run_precheck_before_submit: false
}
deliver_options[:app_identifier] = app_identifier if app_identifier
if app_id && app_identifier.nil?
# `deliver` prefers app_identifier from Appfile unless explicitly blanked.
deliver_options[:app_identifier] = ""
deliver_options[:app] = app_id
end
deliver(**deliver_options)
if screenshot_upload_requested?
upload_app_store_screenshots_deterministically!(
app_identifier: app_identifier,
app_id: app_id,
short_version: version_metadata[:short_version],
screenshots: screenshots_to_upload
)
end
end
desc "Generate deterministic iOS screenshots for App Store metadata"
lane :screenshots do
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))
capture_ios_screenshots(
project: File.join(ios_root, "OpenClaw.xcodeproj"),
scheme: "OpenClawUITests",
configuration: "Debug",
devices: snapshot_devices,
languages: ["en-US"],
launch_arguments: ["--openclaw-screenshot-mode"],
output_directory: File.join(ios_root, "fastlane", "screenshots"),
clear_previous_screenshots: true,
reinstall_app: true,
concurrent_simulators: false,
override_status_bar: true,
override_status_bar_arguments: SNAPSHOT_STATUS_BAR_ARGUMENTS,
skip_open_summary: true,
xcargs: "-allowProvisioningUpdates"
)
watch_screenshot
end
desc "Generate deterministic Apple Watch screenshot for App Store metadata"
lane :watch_screenshot do
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))
capture_watch_screenshot
end
desc "Validate App Store Connect API auth"
lane :auth_check do
app_store_connect_api_key_config
UI.success("App Store Connect API auth loaded successfully.")
end
end