module Fastlane module Actions class NotarizeAction < Action # rubocop:disable Metrics/PerceivedComplexity def self.run(params) package_path = params[:package] bundle_id = params[:bundle_id] try_early_stapling = params[:try_early_stapling] print_log = params[:print_log] verbose = params[:verbose] # Only set :api_key from SharedValues if :api_key_path isn't set (conflicting options) unless params[:api_key_path] params[:api_key] ||= Actions.lane_context[SharedValues::APP_STORE_CONNECT_API_KEY] end api_key = Spaceship::ConnectAPI::Token.from(hash: params[:api_key], filepath: params[:api_key_path]) use_notarytool = params[:use_notarytool] # Compress and read bundle identifier only for .app bundle. compressed_package_path = nil if File.extname(package_path) == '.app' compressed_package_path = "#{package_path}.zip" Actions.sh( "ditto -c -k --rsrc --keepParent \"#{package_path}\" \"#{compressed_package_path}\"", log: verbose ) unless bundle_id info_plist_path = File.join(package_path, 'Contents', 'Info.plist') bundle_id = Actions.sh( "/usr/libexec/PlistBuddy -c \"Print :CFBundleIdentifier\" \"#{info_plist_path}\"", log: verbose ).strip end end UI.user_error!('Could not read bundle identifier, provide as a parameter') unless bundle_id if use_notarytool notarytool(params, package_path, bundle_id, try_early_stapling, print_log, verbose, api_key, compressed_package_path) else altool(params, package_path, bundle_id, try_early_stapling, print_log, verbose, api_key, compressed_package_path) end end def self.notarytool(params, package_path, bundle_id, try_early_stapling, print_log, verbose, api_key, compressed_package_path) temp_file = nil # Create authorization part of command with either API Key or Apple ID auth_parts = [] if api_key # Writes key contents to temporary file for command require 'tempfile' temp_file = Tempfile.new api_key.write_key_to_file(temp_file.path) auth_parts << "--key #{temp_file.path}" auth_parts << "--key-id #{api_key.key_id}" auth_parts << "--issuer #{api_key.issuer_id}" else auth_parts << "--apple-id #{params[:username]}" auth_parts << "--password #{ENV['FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD']}" auth_parts << "--team-id #{params[:asc_provider]}" end # Submits package and waits for processing using `xcrun notarytool submit --wait` submit_parts = [ "xcrun notarytool submit", (compressed_package_path || package_path).shellescape, "--output-format json", "--wait" ] + auth_parts submit_command = submit_parts.join(' ') submit_response = Actions.sh( submit_command, log: verbose, error_callback: lambda { |msg| UI.error("Error polling for notarization info: #{msg}") } ) notarization_info = JSON.parse(submit_response) # Staple case notarization_info['status'] when 'Accepted' submission_id = notarization_info["id"] UI.success("Successfully uploaded package to notarization service with request identifier #{submission_id}") UI.message('Stapling package') self.staple(package_path, verbose) UI.success("Successfully notarized and stapled package") when 'Invalid' UI.user_error!("Could not notarize package with message '#{notarization_info['statusSummary']}'") else UI.crash!("Could not notarize package with status '#{notarization_info['status']}'") end ensure temp_file.delete if temp_file end def self.altool(params, package_path, bundle_id, try_early_stapling, print_log, verbose, api_key, compressed_package_path) UI.message('Uploading package to notarization service, might take a while') notarization_upload_command = "xcrun altool --notarize-app -t osx -f \"#{compressed_package_path || package_path}\" --primary-bundle-id #{bundle_id} --output-format xml" notarization_info = {} with_notarize_authenticator(params, api_key) do |notarize_authenticator| notarization_upload_command << " --asc-provider \"#{params[:asc_provider]}\"" if params[:asc_provider] && api_key.nil? notarization_upload_response = Actions.sh( notarize_authenticator.call(notarization_upload_command), log: verbose ) FileUtils.rm_rf(compressed_package_path) if compressed_package_path notarization_upload_plist = Plist.parse_xml(notarization_upload_response) if notarization_upload_plist.key?('product-errors') && notarization_upload_plist['product-errors'].any? UI.important("🚫 Could not upload package to notarization service! Here are the reasons:") notarization_upload_plist['product-errors'].each { |product_error| UI.error("#{product_error['message']} (#{product_error['code']})") } UI.user_error!("Package upload to notarization service cancelled. Please check the error messages above.") end notarization_request_id = notarization_upload_plist['notarization-upload']['RequestUUID'] UI.success("Successfully uploaded package to notarization service with request identifier #{notarization_request_id}") while notarization_info.empty? || (notarization_info['Status'] == 'in progress') if notarization_info.empty? UI.message('Waiting to query request status') elsif try_early_stapling UI.message('Request in progress, trying early staple') begin self.staple(package_path, verbose) UI.message('Successfully notarized and early stapled package.') return rescue UI.message('Early staple failed, waiting to query again') end end sleep(30) UI.message('Querying request status') # As of July 2020, the request UUID might not be available for polling yet which returns an error code # This is now handled with the error_callback (which prevents an error from being raised) # Catching this error allows for polling to continue until the notarization is complete error = false notarization_info_response = Actions.sh( notarize_authenticator.call("xcrun altool --notarization-info #{notarization_request_id} --output-format xml"), log: verbose, error_callback: lambda { |msg| error = true UI.error("Error polling for notarization info: #{msg}") } ) unless error notarization_info_plist = Plist.parse_xml(notarization_info_response) notarization_info = notarization_info_plist['notarization-info'] end end end # rubocop:enable Metrics/PerceivedComplexity log_url = notarization_info['LogFileURL'] ENV['FL_NOTARIZE_LOG_FILE_URL'] = log_url log_suffix = '' if log_url && print_log log_response = Net::HTTP.get(URI(log_url)) log_json_object = JSON.parse(log_response) log_suffix = ", with log:\n#{JSON.pretty_generate(log_json_object)}" end case notarization_info['Status'] when 'success' UI.message('Stapling package') self.staple(package_path, verbose) UI.success("Successfully notarized and stapled package#{log_suffix}") when 'invalid' UI.user_error!("Could not notarize package with message '#{notarization_info['Status Message']}'#{log_suffix}") else UI.crash!("Could not notarize package with status '#{notarization_info['Status']}'#{log_suffix}") end ensure ENV.delete('FL_NOTARIZE_PASSWORD') end def self.staple(package_path, verbose) Actions.sh( "xcrun stapler staple #{package_path.shellescape}", log: verbose ) end def self.with_notarize_authenticator(params, api_key) if api_key # From xcrun altool for --apiKey: # This option will search the following directories in sequence for a private key file with the name of 'AuthKey_.p8': './private_keys', '~/private_keys', '~/.private_keys', and '~/.appstoreconnect/private_keys'. api_key_folder_path = File.expand_path('~/.appstoreconnect/private_keys') api_key_file_path = File.join(api_key_folder_path, "AuthKey_#{api_key.key_id}.p8") directory_exists = File.directory?(api_key_folder_path) file_exists = File.exist?(api_key_file_path) begin FileUtils.mkdir_p(api_key_folder_path) unless directory_exists api_key.write_key_to_file(api_key_file_path) unless file_exists yield(proc { |command| "#{command} --apiKey #{api_key.key_id} --apiIssuer #{api_key.issuer_id}" }) ensure FileUtils.rm(api_key_file_path) unless file_exists FileUtils.rm_r(api_key_folder_path) unless directory_exists end else apple_id_account = CredentialsManager::AccountManager.new(user: params[:username]) # Add password as a temporary environment variable for altool. # Use app specific password if specified. ENV['FL_NOTARIZE_PASSWORD'] = ENV['FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD'] || apple_id_account.password yield(proc { |command| "#{command} -u #{apple_id_account.user} -p @env:FL_NOTARIZE_PASSWORD" }) end end def self.description 'Notarizes a macOS app' end def self.authors ['zeplin'] end def self.available_options username = CredentialsManager::AppfileConfig.try_fetch_value(:apple_dev_portal_id) username ||= CredentialsManager::AppfileConfig.try_fetch_value(:apple_id) asc_provider = CredentialsManager::AppfileConfig.try_fetch_value(:itc_team_id) [ FastlaneCore::ConfigItem.new(key: :package, env_name: 'FL_NOTARIZE_PACKAGE', description: 'Path to package to notarize, e.g. .app bundle or disk image', verify_block: proc do |value| UI.user_error!("Could not find package at '#{value}'") unless File.exist?(value) end), FastlaneCore::ConfigItem.new(key: :use_notarytool, env_name: 'FL_NOTARIZE_USE_NOTARYTOOL', description: 'Whether to `xcrun notarytool` or `xcrun altool`', default_value: Helper.mac? && Helper.xcode_at_least?("13.0"), # Notary tool added in Xcode 13 default_value_dynamic: true, type: Boolean), FastlaneCore::ConfigItem.new(key: :try_early_stapling, env_name: 'FL_NOTARIZE_TRY_EARLY_STAPLING', description: 'Whether to try early stapling while the notarization request is in progress', optional: true, default_value: false, type: Boolean), FastlaneCore::ConfigItem.new(key: :bundle_id, env_name: 'FL_NOTARIZE_BUNDLE_ID', description: 'Bundle identifier to uniquely identify the package', optional: true), FastlaneCore::ConfigItem.new(key: :username, env_name: 'FL_NOTARIZE_USERNAME', description: 'Apple ID username', default_value: username, optional: true, conflicting_options: [:api_key_path, :api_key], default_value_dynamic: true), FastlaneCore::ConfigItem.new(key: :asc_provider, env_name: 'FL_NOTARIZE_ASC_PROVIDER', description: 'Provider short name for accounts associated with multiple providers', optional: true, default_value: asc_provider), FastlaneCore::ConfigItem.new(key: :print_log, env_name: 'FL_NOTARIZE_PRINT_LOG', description: 'Whether to print notarization log file, listing issues on failure and warnings on success', optional: true, default_value: false, type: Boolean), FastlaneCore::ConfigItem.new(key: :verbose, env_name: 'FL_NOTARIZE_VERBOSE', description: 'Whether to log requests', optional: true, default_value: false, type: Boolean), FastlaneCore::ConfigItem.new(key: :api_key_path, env_names: ['FL_NOTARIZE_API_KEY_PATH', "APP_STORE_CONNECT_API_KEY_PATH"], description: "Path to your App Store Connect API Key JSON file (https://docs.fastlane.tools/app-store-connect-api/#using-fastlane-api-key-json-file)", optional: true, conflicting_options: [:username, :api_key], verify_block: proc do |value| UI.user_error!("API Key not found at '#{value}'") unless File.exist?(value) end), FastlaneCore::ConfigItem.new(key: :api_key, env_names: ['FL_NOTARIZE_API_KEY', "APP_STORE_CONNECT_API_KEY"], description: "Your App Store Connect API Key information (https://docs.fastlane.tools/app-store-connect-api/#using-fastlane-api-key-hash-option)", optional: true, conflicting_options: [:username, :api_key_path], sensitive: true, type: Hash) ] end def self.is_supported?(platform) platform == :mac end def self.category :code_signing end end end end