mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-30 18:43:32 +00:00
1441 lines
49 KiB
Ruby
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
|