require "shellwords" require "open3" require "json" default_platform(:ios) BETA_APP_IDENTIFIER = "ai.openclaw.client" 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 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 ASC key content from Keychain.") return decoded end rescue StandardError return candidate end candidate end def read_asc_key_content_from_keychain service = ENV["ASC_KEYCHAIN_SERVICE"] service = "openclaw-asc-key" unless env_present?(service) account = ENV["ASC_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 ASC 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 normalize_release_version(raw_value) version = raw_value.to_s.strip.sub(/\Av/, "") UI.user_error!("Missing root package.json version.") unless env_present?(version) unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i) UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.9 or 2026.3.9-beta.1.") end version end def read_root_package_version package_json_path = File.join(repo_root, "package.json") UI.user_error!("Missing package.json at #{package_json_path}.") unless File.exist?(package_json_path) parsed = JSON.parse(File.read(package_json_path)) normalize_release_version(parsed["version"]) rescue JSON::ParserError => e UI.user_error!("Invalid package.json at #{package_json_path}: #{e.message}") end def short_release_version(version) normalize_release_version(version).sub(/([.-]?beta[.-]\d+)\z/i, "") end def shell_join(parts) Shellwords.join(parts.compact) end def resolve_beta_build_number(api_key:, version:) explicit = ENV["IOS_BETA_BUILD_NUMBER"] if env_present?(explicit) UI.user_error!("Invalid IOS_BETA_BUILD_NUMBER '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/) UI.message("Using explicit iOS beta build number #{explicit}.") return explicit end short_version = short_release_version(version) latest_build = latest_testflight_build_number( api_key: api_key, app_identifier: BETA_APP_IDENTIFIER, version: short_version, initial_build_number: 0 ) next_build = latest_build.to_i + 1 UI.message("Resolved iOS beta build number #{next_build} for #{short_version} (latest TestFlight build: #{latest_build}).") next_build.to_s end def beta_build_number_needs_asc_auth? explicit = ENV["IOS_BETA_BUILD_NUMBER"] !env_present?(explicit) end def prepare_beta_release!(version:, build_number:) script_path = File.join(repo_root, "scripts", "ios-beta-prepare.sh") UI.message("Preparing iOS beta release #{version} (build #{build_number}).") sh(shell_join(["bash", script_path, "--build-number", build_number])) beta_xcconfig = File.join(ios_root, "build", "BetaRelease.xcconfig") UI.user_error!("Missing beta xcconfig at #{beta_xcconfig}.") unless File.exist?(beta_xcconfig) ENV["XCODE_XCCONFIG_FILE"] = beta_xcconfig beta_xcconfig end def build_beta_release(context) version = context[:version] output_directory = File.join("build", "beta") archive_path = File.join(output_directory, "OpenClaw-#{version}.xcarchive") build_app( project: "OpenClaw.xcodeproj", scheme: "OpenClaw", configuration: "Release", export_method: "app-store", clean: true, skip_profile_detection: true, build_path: "build", archive_path: archive_path, output_directory: output_directory, output_name: "OpenClaw-#{version}.ipa", xcargs: "-allowProvisioningUpdates", export_xcargs: "-allowProvisioningUpdates", export_options: { signingStyle: "automatic" } ) { archive_path: archive_path, build_number: context[:build_number], ipa_path: lane_context[SharedValues::IPA_OUTPUT_PATH], short_version: context[:short_version], version: version } end platform :ios do private_lane :asc_api_key do load_env_file(File.join(__dir__, ".env")) clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH") clear_empty_env_var("ASC_KEY_PATH") clear_empty_env_var("ASC_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["ASC_KEY_PATH"] if env_present?(p8_path) key_id = ENV["ASC_KEY_ID"] issuer_id = ENV["ASC_ISSUER_ID"] UI.user_error!("Missing ASC_KEY_ID or ASC_ISSUER_ID for ASC_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["ASC_KEY_ID"] issuer_id = ENV["ASC_ISSUER_ID"] key_content = ENV["ASC_KEY_CONTENT"] key_content = read_asc_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), ASC_KEY_PATH (p8), or ASC_KEY_ID/ASC_ISSUER_ID with ASC_KEY_CONTENT (or Keychain via ASC_KEYCHAIN_SERVICE/ASC_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 private_lane :prepare_beta_context do |options| require_api_key = options[:require_api_key] == true needs_api_key = require_api_key || beta_build_number_needs_asc_auth? api_key = needs_api_key ? asc_api_key : nil version = read_root_package_version build_number = resolve_beta_build_number(api_key: api_key, version: version) beta_xcconfig = prepare_beta_release!(version: version, build_number: build_number) { api_key: api_key, beta_xcconfig: beta_xcconfig, build_number: build_number, short_version: short_release_version(version), version: version } end desc "Build a beta archive locally without uploading" lane :beta_archive do context = prepare_beta_context(require_api_key: false) build = build_beta_release(context) UI.success("Built iOS beta archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}") build ensure ENV.delete("XCODE_XCCONFIG_FILE") end desc "Build + upload a beta to TestFlight" lane :beta do context = prepare_beta_context(require_api_key: true) build = build_beta_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 beta: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}") ensure ENV.delete("XCODE_XCCONFIG_FILE") end desc "Upload App Store metadata (and optionally screenshots)" lane :metadata do api_key = asc_api_key clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH") app_identifier = ENV["ASC_APP_IDENTIFIER"] app_id = ENV["ASC_APP_ID"] app_identifier = nil unless env_present?(app_identifier) app_id = nil unless env_present?(app_id) deliver_options = { api_key: api_key, force: true, skip_screenshots: ENV["DELIVER_SCREENSHOTS"] != "1", skip_metadata: ENV["DELIVER_METADATA"] != "1", 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) end desc "Validate App Store Connect API auth" lane :auth_check do asc_api_key UI.success("App Store Connect API auth loaded successfully.") end end