diff --git a/deliver/lib/deliver/runner.rb b/deliver/lib/deliver/runner.rb index 20460538466..d4c39bc67dd 100644 --- a/deliver/lib/deliver/runner.rb +++ b/deliver/lib/deliver/runner.rb @@ -206,7 +206,7 @@ def upload_binary pkg_path = options[:pkg] platform = options[:platform] - transporter = transporter_for_selected_team + transporter = transporter_for_selected_team(upload: true) case platform when "ios", "appletvos" @@ -216,7 +216,7 @@ def upload_binary package_path: "/tmp", platform: platform ) - result = transporter.upload(package_path: package_path, asset_path: ipa_path) + result = transporter.upload(package_path: package_path, asset_path: ipa_path, platform: platform) when "osx" package_path = FastlaneCore::PkgUploadPackageBuilder.new.generate( app_id: Deliver.cache[:app].id, @@ -224,7 +224,7 @@ def upload_binary package_path: "/tmp", platform: platform ) - result = transporter.upload(package_path: package_path, asset_path: pkg_path) + result = transporter.upload(package_path: package_path, asset_path: pkg_path, platform: platform) else UI.user_error!("No suitable file found for upload for platform: #{options[:platform]}") end @@ -270,17 +270,24 @@ def submit_for_review # If itc_provider was explicitly specified, use it. # If there are multiple teams, infer the provider from the selected team name. # If there are fewer than two teams, don't infer the provider. - def transporter_for_selected_team + def transporter_for_selected_team(upload: false) # Use JWT auth api_token = Spaceship::ConnectAPI.token + api_key = if options[:api_key].nil? && !api_token.nil? + # Load api key info if user set api_key_path, not api_key + { key_id: api_token.key_id, issuer_id: api_token.issuer_id, key: api_token.key_raw, is_key_content_base64: api_token.is_key_content_base64 } + elsif !options[:api_key].nil? + options[:api_key].transform_keys(&:to_sym) + end + unless api_token.nil? api_token.refresh! if api_token.expired? - return FastlaneCore::ItunesTransporter.new(nil, nil, false, nil, api_token.text) + return FastlaneCore::ItunesTransporter.new(nil, nil, false, nil, api_token.text, upload: upload, api_key: api_key) end tunes_client = Spaceship::ConnectAPI.client.tunes_client - generic_transporter = FastlaneCore::ItunesTransporter.new(options[:username], nil, false, options[:itc_provider]) + generic_transporter = FastlaneCore::ItunesTransporter.new(options[:username], nil, false, options[:itc_provider], upload: upload, api_key: api_key) return generic_transporter unless options[:itc_provider].nil? && tunes_client.teams.count > 1 begin @@ -288,7 +295,7 @@ def transporter_for_selected_team name = team['name'] provider_id = generic_transporter.provider_ids[name] UI.verbose("Inferred provider id #{provider_id} for team #{name}.") - return FastlaneCore::ItunesTransporter.new(options[:username], nil, false, provider_id) + return FastlaneCore::ItunesTransporter.new(options[:username], nil, false, provider_id, upload: upload, api_key: api_key) rescue => ex UI.verbose("Couldn't infer a provider short name for team with id #{tunes_client.team_id} automatically: #{ex}. Proceeding without provider short name.") return generic_transporter diff --git a/deliver/spec/runner_spec.rb b/deliver/spec/runner_spec.rb index 1fcd03e2de8..00c07f69616 100644 --- a/deliver/spec/runner_spec.rb +++ b/deliver/spec/runner_spec.rb @@ -63,7 +63,7 @@ def provider_ids expect_any_instance_of(FastlaneCore::IpaUploadPackageBuilder).to receive(:generate) .with(app_id: 'YI8C2AS', ipa_path: 'ACME.ipa', package_path: '/tmp', platform: 'ios') .and_return('path') - expect(transporter).to receive(:upload).with(package_path: 'path', asset_path: 'ACME.ipa').and_return(true) + expect(transporter).to receive(:upload).with(package_path: 'path', asset_path: 'ACME.ipa', platform: 'ios').and_return(true) runner.upload_binary end end @@ -77,7 +77,7 @@ def provider_ids expect_any_instance_of(FastlaneCore::IpaUploadPackageBuilder).to receive(:generate) .with(app_id: 'YI8C2AS', ipa_path: 'ACME.ipa', package_path: '/tmp', platform: 'appletvos') .and_return('path') - expect(transporter).to receive(:upload).with(package_path: 'path', asset_path: 'ACME.ipa').and_return(true) + expect(transporter).to receive(:upload).with(package_path: 'path', asset_path: 'ACME.ipa', platform: 'appletvos').and_return(true) runner.upload_binary end end @@ -93,7 +93,7 @@ def provider_ids expect_any_instance_of(FastlaneCore::PkgUploadPackageBuilder).to receive(:generate) .with(app_id: 'YI8C2AS', pkg_path: 'ACME.pkg', package_path: '/tmp', platform: 'osx') .and_return('path') - expect(transporter).to receive(:upload).with(package_path: 'path', asset_path: 'ACME.pkg').and_return(true) + expect(transporter).to receive(:upload).with(package_path: 'path', asset_path: 'ACME.pkg', platform: 'osx').and_return(true) runner.upload_binary end end diff --git a/fastlane_core/lib/fastlane_core/itunes_transporter.rb b/fastlane_core/lib/fastlane_core/itunes_transporter.rb index cde8a51d7d8..db2299a66f2 100644 --- a/fastlane_core/lib/fastlane_core/itunes_transporter.rb +++ b/fastlane_core/lib/fastlane_core/itunes_transporter.rb @@ -28,10 +28,29 @@ class TransporterExecutor OUTPUT_REGEX = />\s+(.+)/ RETURN_VALUE_REGEX = />\sDBG-X:\sReturning\s+(\d+)/ + # Matches a line in the iTMSTransporter provider table: "12 Initech Systems Inc LG89CQY559" + ITMS_PROVIDER_REGEX = /^\d+\s{2,}.+\s{2,}[^\s]+$/ + SKIP_ERRORS = ["ERROR: An exception has occurred: Scheduling automatic restart in 1 minute"] private_constant :ERROR_REGEX, :WARNING_REGEX, :OUTPUT_REGEX, :RETURN_VALUE_REGEX, :SKIP_ERRORS + def build_download_command(username, password, apple_id, destination = "/tmp", provider_short_name = "", jwt = nil) + not_implemented(__method__) + end + + def build_provider_ids_command(username, password, jwt = nil, api_key = nil) + not_implemented(__method__) + end + + def build_upload_command(username, password, source = "/tmp", provider_short_name = "", jwt = nil, platform = nil, api_key = nil) + not_implemented(__method__) + end + + def build_verify_command(username, password, source = "/tmp", provider_short_name = "", jwt = nil) + not_implemented(__method__) + end + def execute(command, hide_output) if Helper.test? yield(nil) if block_given? @@ -100,8 +119,18 @@ def displayable_errors @errors.map { |error| "[Transporter Error Output]: #{error}" }.join("\n").gsub!(/"/, "") end + def parse_provider_info(lines) + lines.map { |line| itms_provider_pair(line) }.compact.to_h + end + private + def itms_provider_pair(line) + line = line.strip + return nil unless line =~ ITMS_PROVIDER_REGEX + line.split(/\s{2,}/).drop(1) + end + def parse_line(line, hide_output) # Taken from https://github.com/sshaw/itunes_store_transporter/blob/master/lib/itunes/store/transporter/output_parser.rb @@ -180,9 +209,166 @@ def additional_upload_parameters end end + # Generates commands and executes the altool. + class AltoolTransporterExecutor < TransporterExecutor + ERROR_REGEX = /\*\*\* Error:\s+(.+)/ + + private_constant :ERROR_REGEX + + def execute(command, hide_output) + if Helper.test? + yield(nil) if block_given? + return command + end + + @errors = [] + @all_lines = [] + + if hide_output + # Show a one time message instead + UI.success("Waiting for App Store Connect transporter to be finished.") + UI.success("Application Loader progress... this might take a few minutes...") + end + + begin + exit_status = FastlaneCore::FastlanePty.spawn(command) do |command_stdout, command_stdin, pid| + command_stdout.each do |line| + @all_lines << line + parse_line(line, hide_output) # this is where the parsing happens + end + end + rescue => ex + # FastlanePty adds exit_status on to StandardError so every error will have a status code + exit_status = ex.exit_status + @errors << ex.to_s + end + + @errors << "The call to the altool completed with a non-zero exit status: #{exit_status}. This indicates a failure." unless exit_status.zero? + + unless @errors.empty? || @all_lines.empty? + # Print the last lines that appear after the last error from the logs + # If error text is not detected, it will be 20 lines + # This is key for non-verbose mode + + # The format of altool's result with error is like below + # > *** Error: Error uploading '...'. + # > *** Error: ... + # > { + # > NSLocalizedDescription = "...", + # > ... + # > } + # So this line tries to find the line which has "*** Error:" prefix from bottom of log + error_line_index = @all_lines.rindex { |line| ERROR_REGEX.match?(line) } + + @all_lines[(error_line_index || -20)..-1].each do |line| + UI.important("[altool] #{line}") + end + UI.message("Application Loader output above ^") + @errors.each { |error| UI.error(error) } + end + + yield(@all_lines) if block_given? + exit_status.zero? + end + + def build_upload_command(username, password, source = "/tmp", provider_short_name = "", jwt = nil, platform = nil, api_key = nil) + use_api_key = !api_key.nil? + [ + ("API_PRIVATE_KEYS_DIR=#{api_key[:key_dir]}" if use_api_key), + "xcrun altool", + "--upload-app", + ("-u #{username.shellescape}" unless use_api_key), + ("-p #{password.shellescape}" unless use_api_key), + ("--apiKey #{api_key[:key_id]}" if use_api_key), + ("--apiIssuer #{api_key[:issuer_id]}" if use_api_key), + ("--asc-provider #{provider_short_name}" unless use_api_key || provider_short_name.to_s.empty?), + platform_option(platform), + file_upload_option(source), + additional_upload_parameters, + "-k 100000" + ].compact.join(' ') + end + + def build_provider_ids_command(username, password, jwt = nil, api_key = nil) + use_api_key = !api_key.nil? + [ + ("API_PRIVATE_KEYS_DIR=#{api_key[:key_dir]}" if use_api_key), + "xcrun altool", + "--list-providers", + ("-u #{username.shellescape}" unless use_api_key), + ("-p #{password.shellescape}" unless use_api_key), + ("--apiKey #{api_key[:key_id]}" if use_api_key), + ("--apiIssuer #{api_key[:issuer_id]}" if use_api_key), + "--output-format json" + ].compact.join(' ') + end + + def build_download_command(username, password, apple_id, destination = "/tmp", provider_short_name = "", jwt = nil) + raise "This feature has not been implemented yet with altool for Xcode 14" + end + + def build_verify_command(username, password, source = "/tmp", provider_short_name = "", jwt = nil) + raise "This feature has not been implemented yet with altool for Xcode 14" + end + + def additional_upload_parameters + env_deliver_additional_params = ENV["DELIVER_ALTOOL_ADDITIONAL_UPLOAD_PARAMETERS"] + return nil if env_deliver_additional_params.to_s.strip.empty? + + env_deliver_additional_params.to_s.strip + end + + def handle_error(password) + UI.error("Could not download/upload from App Store Connect!") + end + + def displayable_errors + @errors.map { |error| "[Application Loader Error Output]: #{error}" }.join("\n") + end + + def parse_provider_info(lines) + # This tries parsing the provider id from altool output to detect provider list + provider_info = {} + json_body = lines[-2] # altool outputs result in second line from last + return provider_info if json_body.nil? + providers = JSON.parse(json_body)["providers"] + return provider_info if providers.nil? + providers.each do |provider| + provider_info[provider["ProviderName"]] = provider["ProviderShortname"] + end + provider_info + end + + private + + def file_upload_option(source) + "-f #{source.shellescape}" + end + + def platform_option(platform) + "-t #{platform == 'osx' ? 'macos' : platform}" + end + + def parse_line(line, hide_output) + output_done = false + + if line =~ ERROR_REGEX + @errors << $1 + output_done = true + end + + unless hide_output + # General logging for debug purposes + unless output_done + UI.verbose("[altool]: #{line}") + end + end + end + end + # Generates commands and executes the iTMSTransporter through the shell script it provides by the same name class ShellScriptTransporterExecutor < TransporterExecutor - def build_upload_command(username, password, source = "/tmp", provider_short_name = "", jwt = nil) + def build_upload_command(username, password, source = "/tmp", provider_short_name = "", jwt = nil, platform = nil, api_key = nil) use_jwt = !jwt.to_s.empty? [ '"' + Helper.transporter_path + '"', @@ -212,7 +398,7 @@ def build_download_command(username, password, apple_id, destination = "/tmp", p ].compact.join(' ') end - def build_provider_ids_command(username, password, jwt = nil) + def build_provider_ids_command(username, password, jwt = nil, api_key = nil) use_jwt = !jwt.to_s.empty? [ '"' + Helper.transporter_path + '"', @@ -278,7 +464,7 @@ def shell_escaped_password(password) # Generates commands and executes the iTMSTransporter by invoking its Java app directly, to avoid the crazy parameter # escaping problems in its accompanying shell script. class JavaTransporterExecutor < TransporterExecutor - def build_upload_command(username, password, source = "/tmp", provider_short_name = "", jwt = nil) + def build_upload_command(username, password, source = "/tmp", provider_short_name = "", jwt = nil, platform = nil, api_key = nil) use_jwt = !jwt.to_s.empty? if !Helper.user_defined_itms_path? && Helper.mac? && Helper.xcode_at_least?(11) [ @@ -392,7 +578,7 @@ def build_download_command(username, password, apple_id, destination = "/tmp", p end end - def build_provider_ids_command(username, password, jwt = nil) + def build_provider_ids_command(username, password, jwt = nil, api_key = nil) use_jwt = !jwt.to_s.empty? if !Helper.user_defined_itms_path? && Helper.mac? && Helper.xcode_at_least?(11) [ @@ -451,8 +637,6 @@ def execute(command, hide_output) end class ItunesTransporter - # Matches a line in the provider table: "12 Initech Systems Inc LG89CQY559" - PROVIDER_REGEX = /^\d+\s{2,}.+\s{2,}[^\s]+$/ TWO_STEP_HOST_PREFIX = "deliver.appspecific" # This will be called from the Deliverfile, and disables the logging of the transporter output @@ -476,7 +660,7 @@ def self.hide_transporter_output? # see: https://github.com/fastlane/fastlane/issues/1524#issuecomment-196370628 # for more information about how to use the iTMSTransporter to list your provider # short names - def initialize(user = nil, password = nil, use_shell_script = false, provider_short_name = nil, jwt = nil) + def initialize(user = nil, password = nil, use_shell_script = false, provider_short_name = nil, jwt = nil, upload: false, api_key: nil) # Xcode 6.x doesn't have the same iTMSTransporter Java setup as later Xcode versions, so # we can't default to using the newer direct Java invocation strategy for those versions. use_shell_script ||= Helper.is_mac? && Helper.xcode_version.start_with?('6.') @@ -489,8 +673,16 @@ def initialize(user = nil, password = nil, use_shell_script = false, provider_sh end @jwt = jwt + @api_key = api_key + + if should_use_altool?(upload, use_shell_script) + UI.verbose("Using altool as transporter.") + @transporter_executor = AltoolTransporterExecutor.new + else + UI.verbose("Using iTMSTransporter as transporter.") + @transporter_executor = use_shell_script ? ShellScriptTransporterExecutor.new : JavaTransporterExecutor.new + end - @transporter_executor = use_shell_script ? ShellScriptTransporterExecutor.new : JavaTransporterExecutor.new @provider_short_name = provider_short_name end @@ -539,7 +731,7 @@ def download(app_id, dir = nil) # @return (Bool) True if everything worked fine # @raise [Deliver::TransporterTransferError] when something went wrong # when transferring - def upload(app_id = nil, dir = nil, package_path: nil, asset_path: nil) + def upload(app_id = nil, dir = nil, package_path: nil, asset_path: nil, platform: nil) raise "app_id and dir are required or package_path or asset_path is required" if (app_id.nil? || dir.nil?) && package_path.nil? && asset_path.nil? # Transport can upload .ipa, .dmg, and .pkg files directly with -assetFile @@ -569,14 +761,25 @@ def upload(app_id = nil, dir = nil, package_path: nil, asset_path: nil) password_placeholder = @jwt.nil? ? 'YourPassword' : nil jwt_placeholder = @jwt.nil? ? nil : 'YourJWT' - command = @transporter_executor.build_upload_command(@user, @password, actual_dir, @provider_short_name, @jwt) - UI.verbose(@transporter_executor.build_upload_command(@user, password_placeholder, actual_dir, @provider_short_name, jwt_placeholder)) + # Handle AppStore Connect API + use_api_key = !@api_key.nil? + api_key_placeholder = use_api_key ? { key_id: "YourKeyID", issuer_id: "YourIssuerID", key_dir: "YourTmpP8KeyDir" } : nil + + api_key = nil + api_key = api_key_with_p8_file_path(@api_key) if use_api_key + + command = @transporter_executor.build_upload_command(@user, @password, actual_dir, @provider_short_name, @jwt, platform, api_key) + UI.verbose(@transporter_executor.build_upload_command(@user, password_placeholder, actual_dir, @provider_short_name, jwt_placeholder, platform, api_key_placeholder)) begin result = @transporter_executor.execute(command, ItunesTransporter.hide_transporter_output?) rescue TransporterRequiresApplicationSpecificPasswordError => ex handle_two_step_failure(ex) return upload(app_id, dir, package_path: package_path, asset_path: asset_path) + ensure + if use_api_key + FileUtils.rm_rf(api_key[:key_dir]) unless api_key.nil? + end end if result @@ -638,8 +841,15 @@ def provider_ids password_placeholder = @jwt.nil? ? 'YourPassword' : nil jwt_placeholder = @jwt.nil? ? nil : 'YourJWT' - command = @transporter_executor.build_provider_ids_command(@user, @password, @jwt) - UI.verbose(@transporter_executor.build_provider_ids_command(@user, password_placeholder, jwt_placeholder)) + # Handle AppStore Connect API + use_api_key = !@api_key.nil? + api_key_placeholder = use_api_key ? { key_id: "YourKeyID", issuer_id: "YourIssuerID", key_dir: "YourTmpP8KeyDir" } : nil + + api_key = nil + api_key = api_key_with_p8_file_path(@api_key) if use_api_key + + command = @transporter_executor.build_provider_ids_command(@user, @password, @jwt, api_key) + UI.verbose(@transporter_executor.build_provider_ids_command(@user, password_placeholder, jwt_placeholder, api_key_placeholder)) lines = [] begin @@ -648,15 +858,37 @@ def provider_ids rescue TransporterRequiresApplicationSpecificPasswordError => ex handle_two_step_failure(ex) return provider_ids + ensure + if use_api_key + FileUtils.rm_rf(api_key[:key_dir]) unless api_key.nil? + end end - lines.map { |line| provider_pair(line) }.compact.to_h + @transporter_executor.parse_provider_info(lines) end private TWO_FACTOR_ENV_VARIABLE = "FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD" + # Create .p8 file from api_key and provide api key info which contains .p8 file path + def api_key_with_p8_file_path(original_api_key) + api_key = original_api_key.clone + api_key[:key_dir] = Dir.mktmpdir("deliver-") + # Specified p8 needs to be generated to call altool + File.open(File.join(api_key[:key_dir], "AuthKey_#{api_key[:key_id]}.p8"), "wb") do |p8| + key_content = api_key[:is_key_content_base64] ? Base64.decode64(api_key[:key]) : api_key[:key] + p8.write(key_content) + end + api_key + end + + # Returns whether altool should be used or ItunesTransporter should be used + def should_use_altool?(upload, use_shell_script) + # Xcode 14 no longer supports iTMSTransporter. Use altool instead + !use_shell_script && upload && !Helper.user_defined_itms_path? && Helper.mac? && Helper.xcode_at_least?(14) + end + # Returns the password to be used with the transporter def load_password_for_transporter # 3 different sources for the password @@ -714,11 +946,5 @@ def handle_two_step_failure(ex) def handle_error(password) @transporter_executor.handle_error(password) end - - def provider_pair(line) - line = line.strip - return nil unless line =~ PROVIDER_REGEX - line.split(/\s{2,}/).drop(1) - end end end diff --git a/fastlane_core/spec/helper_spec.rb b/fastlane_core/spec/helper_spec.rb index def7af07be3..fdd185eec7d 100644 --- a/fastlane_core/spec/helper_spec.rb +++ b/fastlane_core/spec/helper_spec.rb @@ -144,7 +144,9 @@ end it "#transporter_path", requires_xcode: true do - expect(FastlaneCore::Helper.transporter_path).to match(%r{/Applications/Xcode.*.app/Contents/Applications/Application Loader.app/Contents/itms/bin/iTMSTransporter|/Applications/Xcode.*.app/Contents/SharedFrameworks/ContentDeliveryServices.framework/Versions/A/itms/bin/iTMSTransporter}) + unless FastlaneCore::Helper.xcode_at_least?("14") + expect(FastlaneCore::Helper.transporter_path).to match(%r{/Applications/Xcode.*.app/Contents/Applications/Application Loader.app/Contents/itms/bin/iTMSTransporter|/Applications/Xcode.*.app/Contents/SharedFrameworks/ContentDeliveryServices.framework/Versions/A/itms/bin/iTMSTransporter}) + end end it "#xcode_version", requires_xcode: true do @@ -178,7 +180,13 @@ context "#itms_path" do it "default", requires_xcode: true do stub_const('ENV', { 'FASTLANE_ITUNES_TRANSPORTER_PATH' => nil }) - expect(FastlaneCore::Helper.itms_path).to match(/itms/) + + if FastlaneCore::Helper.xcode_at_least?("14") + expect(FastlaneCore::UI).to receive(:user_error!).with(/Could not find transporter/) + expect { FastlaneCore::Helper.itms_path }.not_to raise_error + else + expect(FastlaneCore::Helper.itms_path).to match(/itms/) + end end it "uses FASTLANE_ITUNES_TRANSPORTER_PATH", requires_xcode: true do diff --git a/fastlane_core/spec/itunes_transporter_spec.rb b/fastlane_core/spec/itunes_transporter_spec.rb index 0d7faaa1326..133724461d5 100644 --- a/fastlane_core/spec/itunes_transporter_spec.rb +++ b/fastlane_core/spec/itunes_transporter_spec.rb @@ -5,6 +5,7 @@ let(:password) { "!> p@$s_-+=w'o%rd\"&#*<" } let(:email) { 'fabric.devtools@gmail.com' } let(:jwt) { '409jjl43j90ghjqoineio49024' } + let(:api_key) { { key_id: "TESTAPIK2HW", issuer_id: "11223344-1122-aabb-aabb-uuvvwwxxyyzz" } } describe FastlaneCore::ItunesTransporter do let(:random_uuid) { '2a912f38-5dbc-4fc3-a5b3-1bf184b2b021' } @@ -93,6 +94,40 @@ def shell_provider_id_command(jwt: nil) ].compact.join(' ') end + def altool_upload_command(api_key: nil, platform: "macos", provider_short_name: "") + use_api_key = !api_key.nil? + upload_part = "-f /tmp/my.app.id.itmsp" + escaped_password = password.shellescape + + [ + "xcrun altool", + "--upload-app", + ("-u #{email.shellescape}" unless use_api_key), + ("-p #{escaped_password}" unless use_api_key), + ("--apiKey #{api_key[:key_id]}" if use_api_key), + ("--apiIssuer #{api_key[:issuer_id]}" if use_api_key), + ("--asc-provider #{provider_short_name}" unless use_api_key || provider_short_name.to_s.empty?), + ("-t #{platform}"), + upload_part, + "-k 100000" + ].compact.join(' ') + end + + def altool_provider_id_command(api_key: nil) + use_api_key = !api_key.nil? + escaped_password = password.shellescape + + [ + "xcrun altool", + "--list-providers", + ("-u #{email.shellescape}" unless use_api_key), + ("-p #{escaped_password}" unless use_api_key), + ("--apiKey #{api_key[:key_id]}" if use_api_key), + ("--apiIssuer #{api_key[:issuer_id]}" if use_api_key), + "--output-format json" + ].compact.join(' ') + end + def java_upload_command(provider_short_name: nil, transporter: nil, jwt: nil, classpath: true, use_asset_path: false) upload_part = use_asset_path ? "-assetFile /tmp/#{random_uuid}.ipa" : "-f /tmp/my.app.id.itmsp" @@ -1107,9 +1142,79 @@ def xcrun_download_command(provider_short_name = nil, jwt: nil) end end + context "with Xcode 14.x installed" do + before(:each) do + allow(FastlaneCore::Helper).to receive(:xcode_version).and_return('14.0') + allow(FastlaneCore::Helper).to receive(:mac?).and_return(true) + allow(FastlaneCore::Helper).to receive(:windows?).and_return(false) + end + + context "with username and password" do + context "with default itms_path" do + before(:each) do + allow(FastlaneCore::Helper).to receive(:itms_path).and_return(nil) + stub_const('ENV', { 'FASTLANE_ITUNES_TRANSPORTER_PATH' => nil }) + end + context "upload command generation" do + it 'generates a call to altool' do + transporter = FastlaneCore::ItunesTransporter.new(email, password, false, 'abcd123', upload: true) + expect(transporter.upload('my.app.id', '/tmp', package_path: '/tmp/my.app.id.itmsp', platform: "osx")).to eq(altool_upload_command(provider_short_name: 'abcd123')) + end + end + + context "provider IDs command generation" do + it 'generates a call to altool' do + transporter = FastlaneCore::ItunesTransporter.new(email, password, false, 'abcd123', upload: true) + expect(transporter.provider_ids).to eq(altool_provider_id_command) + end + end + end + + context "with user defined itms_path" do + before(:each) do + allow(FastlaneCore::Helper).to receive(:itms_path).and_return('/tmp') + stub_const('ENV', { 'FASTLANE_ITUNES_TRANSPORTER_PATH' => '/tmp' }) + end + context "upload command generation" do + it 'generates a call to xcrun iTMSTransporter instead altool' do + transporter = FastlaneCore::ItunesTransporter.new(email, password, false, 'abcd123', upload: true) + expect(transporter.upload('my.app.id', '/tmp', platform: "osx")).to eq(java_upload_command(provider_short_name: 'abcd123', classpath: false)) + end + end + end + + after(:each) { ENV.delete("FASTLANE_ITUNES_TRANSPORTER_PATH") } + end + + context "with api_key" do + context "with default itms_path" do + before(:each) do + allow(FastlaneCore::Helper).to receive(:itms_path).and_return(nil) + stub_const('ENV', { 'FASTLANE_ITUNES_TRANSPORTER_PATH' => nil }) + end + context "upload command generation" do + it 'generates a call to altool' do + transporter = FastlaneCore::ItunesTransporter.new(email, password, false, 'abcd123', upload: true, api_key: api_key) + expected = Regexp.new("API_PRIVATE_KEYS_DIR=#{Regexp.escape(Dir.tmpdir)}.*\s#{Regexp.escape(altool_upload_command(api_key: api_key, provider_short_name: 'abcd123'))}") + expect(transporter.upload('my.app.id', '/tmp', platform: "osx")).to match(expected) + end + end + + context "provider IDs command generation" do + it 'generates a call to altool' do + transporter = FastlaneCore::ItunesTransporter.new(email, password, false, 'abcd123', upload: true, api_key: api_key) + expected = Regexp.new("API_PRIVATE_KEYS_DIR=#{Regexp.escape(Dir.tmpdir)}.*\s#{Regexp.escape(altool_provider_id_command(api_key: api_key))}") + expect(transporter.provider_ids).to match(expected) + end + end + end + end + end + describe "with `FASTLANE_ITUNES_TRANSPORTER_USE_SHELL_SCRIPT` set" do before(:each) do ENV["FASTLANE_ITUNES_TRANSPORTER_USE_SHELL_SCRIPT"] = "1" + allow(FastlaneCore::Helper).to receive(:itms_path).and_return('/tmp') allow(File).to receive(:exist?).with("C:/Program Files (x86)/itms").and_return(true) if FastlaneCore::Helper.windows? end @@ -1139,6 +1244,7 @@ def xcrun_download_command(provider_short_name = nil, jwt: nil) describe "with no special configuration" do before(:each) do + allow(FastlaneCore::Helper).to receive(:itms_path).and_return('/tmp') allow(File).to receive(:exist?).and_return(true) unless FastlaneCore::Helper.mac? ENV.delete("FASTLANE_ITUNES_TRANSPORTER_USE_SHELL_SCRIPT") end @@ -1273,6 +1379,7 @@ def xcrun_download_command(provider_short_name = nil, jwt: nil) describe "with simulated no-test environment" do before(:each) do allow(FastlaneCore::Helper).to receive(:test?).and_return(false) + allow(FastlaneCore::Helper).to receive(:itms_path).and_return('/tmp') @transporter = FastlaneCore::ItunesTransporter.new(email, password, false) end diff --git a/pilot/lib/pilot/build_manager.rb b/pilot/lib/pilot/build_manager.rb index bb5ce5ecee2..9ee9da0edbd 100644 --- a/pilot/lib/pilot/build_manager.rb +++ b/pilot/lib/pilot/build_manager.rb @@ -47,7 +47,7 @@ def upload(options) end transporter = transporter_for_selected_team(options) - result = transporter.upload(package_path: package_path, asset_path: asset_path) + result = transporter.upload(package_path: package_path, asset_path: asset_path, platform: platform) unless result transporter_errors = transporter.displayable_errors @@ -389,15 +389,22 @@ def expire_previous_builds(build) def transporter_for_selected_team(options) # Use JWT auth api_token = Spaceship::ConnectAPI.token + api_key = if options[:api_key].nil? && !api_token.nil? + # Load api key info if user set api_key_path, not api_key + { key_id: api_token.key_id, issuer_id: api_token.issuer_id, key: api_token.key_raw, is_key_content_base64: api_token.is_key_content_base64 } + elsif !options[:api_key].nil? + options[:api_key].transform_keys(&:to_sym) + end + unless api_token.nil? api_token.refresh! if api_token.expired? - return FastlaneCore::ItunesTransporter.new(nil, nil, false, nil, api_token.text) + return FastlaneCore::ItunesTransporter.new(nil, nil, false, nil, api_token.text, upload: true, api_key: api_key) end # Otherwise use username and password tunes_client = Spaceship::ConnectAPI.client ? Spaceship::ConnectAPI.client.tunes_client : nil - generic_transporter = FastlaneCore::ItunesTransporter.new(options[:username], nil, false, options[:itc_provider]) + generic_transporter = FastlaneCore::ItunesTransporter.new(options[:username], nil, false, options[:itc_provider], upload: true, api_key: api_key) return generic_transporter if options[:itc_provider] || tunes_client.nil? return generic_transporter unless tunes_client.teams.count > 1 @@ -406,7 +413,7 @@ def transporter_for_selected_team(options) name = team['name'] provider_id = generic_transporter.provider_ids[name] UI.verbose("Inferred provider id #{provider_id} for team #{name}.") - return FastlaneCore::ItunesTransporter.new(options[:username], nil, false, provider_id) + return FastlaneCore::ItunesTransporter.new(options[:username], nil, false, provider_id, upload: true, api_key: api_key) rescue => ex STDERR.puts(ex.to_s) UI.verbose("Couldn't infer a provider short name for team with id #{tunes_client.team_id} automatically: #{ex}. Proceeding without provider short name.") diff --git a/spaceship/lib/spaceship/connect_api/token.rb b/spaceship/lib/spaceship/connect_api/token.rb index ce3e922697d..fd0e495a06a 100644 --- a/spaceship/lib/spaceship/connect_api/token.rb +++ b/spaceship/lib/spaceship/connect_api/token.rb @@ -22,6 +22,7 @@ class Token attr_reader :expiration attr_reader :key_raw + attr_reader :is_key_content_base64 # Temporary attribute not needed to create the JWT text # There is no way to determine if the team associated with this @@ -71,17 +72,19 @@ def self.create(key_id: nil, issuer_id: nil, filepath: nil, key: nil, is_key_con key: OpenSSL::PKey::EC.new(key), key_raw: key, duration: duration, - in_house: in_house + in_house: in_house, + is_key_content_base64: is_key_content_base64 ) end - def initialize(key_id: nil, issuer_id: nil, key: nil, key_raw: nil, duration: nil, in_house: nil) + def initialize(key_id: nil, issuer_id: nil, key: nil, key_raw: nil, duration: nil, in_house: nil, is_key_content_base64: nil) @key_id = key_id @key = key @key_raw = key_raw @issuer_id = issuer_id @duration = duration @in_house = in_house + @is_key_content_base64 = is_key_content_base64 @duration ||= DEFAULT_TOKEN_DURATION @duration = @duration.to_i if @duration