require 'fastlane_core/cert_checker' require 'fastlane_core/provisioning_profile' require 'fastlane_core/print_table' require 'spaceship/client' require_relative 'generator' require_relative 'module' require_relative 'table_printer' require_relative 'spaceship_ensure' require_relative 'utils' require_relative 'storage' require_relative 'encryption' module Match # rubocop:disable Metrics/ClassLength class Runner attr_accessor :files_to_commit attr_accessor :spaceship attr_accessor :storage # rubocop:disable Metrics/PerceivedComplexity def run(params) self.files_to_commit = [] FileUtils.mkdir_p(params[:output_path]) if params[:output_path] FastlaneCore::PrintTable.print_values(config: params, title: "Summary for match #{Fastlane::VERSION}") update_optional_values_depending_on_storage_type(params) # Choose the right storage and encryption implementations self.storage = Storage.for_mode(params[:storage_mode], { git_url: params[:git_url], shallow_clone: params[:shallow_clone], skip_docs: params[:skip_docs], git_branch: params[:git_branch], git_full_name: params[:git_full_name], git_user_email: params[:git_user_email], clone_branch_directly: params[:clone_branch_directly], git_basic_authorization: params[:git_basic_authorization], git_bearer_authorization: params[:git_bearer_authorization], git_private_key: params[:git_private_key], type: params[:type].to_s, generate_apple_certs: params[:generate_apple_certs], platform: params[:platform].to_s, google_cloud_bucket_name: params[:google_cloud_bucket_name].to_s, google_cloud_keys_file: params[:google_cloud_keys_file].to_s, google_cloud_project_id: params[:google_cloud_project_id].to_s, skip_google_cloud_account_confirmation: params[:skip_google_cloud_account_confirmation], s3_region: params[:s3_region], s3_access_key: params[:s3_access_key], s3_secret_access_key: params[:s3_secret_access_key], s3_bucket: params[:s3_bucket], s3_object_prefix: params[:s3_object_prefix], gitlab_project: params[:gitlab_project], gitlab_host: params[:gitlab_host], readonly: params[:readonly], username: params[:readonly] ? nil : params[:username], # only pass username if not readonly team_id: params[:team_id], team_name: params[:team_name], api_key_path: params[:api_key_path], api_key: params[:api_key] }) storage.download # Init the encryption only after the `storage.download` was called to have the right working directory encryption = Encryption.for_storage_mode(params[:storage_mode], { git_url: params[:git_url], working_directory: storage.working_directory }) encryption.decrypt_files if encryption unless params[:readonly] self.spaceship = SpaceshipEnsure.new(params[:username], params[:team_id], params[:team_name], api_token(params)) if params[:type] == "enterprise" && !Spaceship.client.in_house? UI.user_error!("You defined the profile type 'enterprise', but your Apple account doesn't support In-House profiles") end end if params[:app_identifier].kind_of?(Array) app_identifiers = params[:app_identifier] else app_identifiers = params[:app_identifier].to_s.split(/\s*,\s*/).uniq end # sometimes we get an array with arrays, this is a bug. To unblock people using match, I suggest we flatten # then in the future address the root cause of https://github.com/fastlane/fastlane/issues/11324 app_identifiers = app_identifiers.flatten # Verify the App ID (as we don't want 'match' to fail at a later point) if spaceship app_identifiers.each do |app_identifier| spaceship.bundle_identifier_exists(username: params[:username], app_identifier: app_identifier, platform: params[:platform]) end end # Certificate cert_id = fetch_certificate(params: params, working_directory: storage.working_directory) # Mac Installer Distribution Certificate additional_cert_types = params[:additional_cert_types] || [] cert_ids = additional_cert_types.map do |additional_cert_type| fetch_certificate(params: params, working_directory: storage.working_directory, specific_cert_type: additional_cert_type) end cert_ids << cert_id spaceship.certificates_exists(username: params[:username], certificate_ids: cert_ids) if spaceship # Provisioning Profiles unless params[:skip_provisioning_profiles] app_identifiers.each do |app_identifier| loop do break if fetch_provisioning_profile(params: params, certificate_id: cert_id, app_identifier: app_identifier, working_directory: storage.working_directory) end end end if self.files_to_commit.count > 0 && !params[:readonly] encryption.encrypt_files if encryption storage.save_changes!(files_to_commit: self.files_to_commit) end # Print a summary table for each app_identifier app_identifiers.each do |app_identifier| TablePrinter.print_summary(app_identifier: app_identifier, type: params[:type], platform: params[:platform]) end UI.success("All required keys, certificates and provisioning profiles are installed 🙌".green) rescue Spaceship::Client::UnexpectedResponse, Spaceship::Client::InvalidUserCredentialsError, Spaceship::Client::NoUserCredentialsError => ex UI.error("An error occurred while verifying your certificates and profiles with the Apple Developer Portal.") UI.error("If you already have your certificates stored in git, you can run `fastlane match` in readonly mode") UI.error("to just install the certificates and profiles without accessing the Dev Portal.") UI.error("To do so, just pass `readonly: true` to your match call.") raise ex ensure storage.clear_changes if storage end # rubocop:enable Metrics/PerceivedComplexity def api_token(params) api_token = Spaceship::ConnectAPI::Token.from(hash: params[:api_key], filepath: params[:api_key_path]) return api_token end # Used when creating a new certificate or profile def prefixed_working_directory return self.storage.prefixed_working_directory end # Be smart about optional values here # Depending on the storage mode, different values are required def update_optional_values_depending_on_storage_type(params) if params[:storage_mode] != "git" params.option_for_key(:git_url).optional = true end end def fetch_certificate(params: nil, working_directory: nil, specific_cert_type: nil) cert_type = Match.cert_type_sym(specific_cert_type || params[:type]) certs = Dir[File.join(prefixed_working_directory, "certs", cert_type.to_s, "*.cer")] keys = Dir[File.join(prefixed_working_directory, "certs", cert_type.to_s, "*.p12")] if certs.count == 0 || keys.count == 0 UI.important("Couldn't find a valid code signing identity for #{cert_type}... creating one for you now") UI.crash!("No code signing identity found and can not create a new one because you enabled `readonly`") if params[:readonly] cert_path = Generator.generate_certificate(params, cert_type, prefixed_working_directory, specific_cert_type: specific_cert_type) private_key_path = cert_path.gsub(".cer", ".p12") self.files_to_commit << cert_path self.files_to_commit << private_key_path else cert_path = certs.last # Check validity of certificate if Utils.is_cert_valid?(cert_path) UI.verbose("Your certificate '#{File.basename(cert_path)}' is valid") else UI.user_error!("Your certificate '#{File.basename(cert_path)}' is not valid, please check end date and renew it if necessary") end if Helper.mac? UI.message("Installing certificate...") # Only looking for cert in "custom" (non login.keychain) keychain # Doing this for backwards compatibility keychain_name = params[:keychain_name] == "login.keychain" ? nil : params[:keychain_name] if FastlaneCore::CertChecker.installed?(cert_path, in_keychain: keychain_name) UI.verbose("Certificate '#{File.basename(cert_path)}' is already installed on this machine") else Utils.import(cert_path, params[:keychain_name], password: params[:keychain_password]) # Import the private key # there seems to be no good way to check if it's already installed - so just install it # Key will only be added to the partition list if it isn't already installed Utils.import(keys.last, params[:keychain_name], password: params[:keychain_password]) end else UI.message("Skipping installation of certificate as it would not work on this operating system.") end if params[:output_path] FileUtils.cp(cert_path, params[:output_path]) FileUtils.cp(keys.last, params[:output_path]) end # Get and print info of certificate info = Utils.get_cert_info(cert_path) TablePrinter.print_certificate_info(cert_info: info) end return File.basename(cert_path).gsub(".cer", "") # Certificate ID end # rubocop:disable Metrics/PerceivedComplexity # @return [String] The UUID of the provisioning profile so we can verify it with the Apple Developer Portal def fetch_provisioning_profile(params: nil, certificate_id: nil, app_identifier: nil, working_directory: nil) prov_type = Match.profile_type_sym(params[:type]) names = [Match::Generator.profile_type_name(prov_type), app_identifier] if params[:platform].to_s == :tvos.to_s || params[:platform].to_s == :catalyst.to_s names.push(params[:platform]) end profile_name = names.join("_").gsub("*", '\*') # this is important, as it shouldn't be a wildcard base_dir = File.join(prefixed_working_directory, "profiles", prov_type.to_s) extension = ".mobileprovision" if [:macos.to_s, :catalyst.to_s].include?(params[:platform].to_s) extension = ".provisionprofile" end profile_file = "#{profile_name}#{extension}" profiles = Dir[File.join(base_dir, profile_file)] if Helper.mac? keychain_path = FastlaneCore::Helper.keychain_path(params[:keychain_name]) unless params[:keychain_name].nil? end # Install the provisioning profiles profile = profiles.last force = params[:force] if params[:force_for_new_devices] force = should_force_include_all_devices(params: params, prov_type: prov_type, profile: profile, keychain_path: keychain_path) unless force end if params[:include_all_certificates] # Clearing specified certificate id which will prevent a profile being created with only one certificate certificate_id = nil force = should_force_include_all_certificates(params: params, prov_type: prov_type, profile: profile, keychain_path: keychain_path) unless force end if profile.nil? || force if params[:readonly] UI.error("No matching provisioning profiles found for '#{profile_file}'") UI.error("A new one cannot be created because you enabled `readonly`") if Dir.exist?(base_dir) # folder for `prov_type` does not exist on first match use for that type all_profiles = Dir.entries(base_dir).reject { |f| f.start_with?(".") } UI.error("Provisioning profiles in your repo for type `#{prov_type}`:") all_profiles.each { |p| UI.error("- '#{p}'") } end UI.error("If you are certain that a profile should exist, double-check the recent changes to your match repository") UI.user_error!("No matching provisioning profiles found and can not create a new one because you enabled `readonly`. Check the output above for more information.") end profile = Generator.generate_provisioning_profile(params: params, prov_type: prov_type, certificate_id: certificate_id, app_identifier: app_identifier, force: force, working_directory: prefixed_working_directory) self.files_to_commit << profile end if Helper.mac? installed_profile = FastlaneCore::ProvisioningProfile.install(profile, keychain_path) end parsed = FastlaneCore::ProvisioningProfile.parse(profile, keychain_path) uuid = parsed["UUID"] if params[:output_path] FileUtils.cp(profile, params[:output_path]) end if spaceship && !spaceship.profile_exists(type: prov_type, username: params[:username], uuid: uuid, platform: params[:platform]) # This profile is invalid, let's remove the local file and generate a new one File.delete(profile) # This method will be called again, no need to modify `files_to_commit` return nil end Utils.fill_environment(Utils.environment_variable_name(app_identifier: app_identifier, type: prov_type, platform: params[:platform]), uuid) # TeamIdentifier is returned as an array, but we're not sure why there could be more than one Utils.fill_environment(Utils.environment_variable_name_team_id(app_identifier: app_identifier, type: prov_type, platform: params[:platform]), parsed["TeamIdentifier"].first) cert_info = Utils.get_cert_info(parsed["DeveloperCertificates"].first.string).to_h Utils.fill_environment(Utils.environment_variable_name_certificate_name(app_identifier: app_identifier, type: prov_type, platform: params[:platform]), cert_info["Common Name"]) Utils.fill_environment(Utils.environment_variable_name_profile_name(app_identifier: app_identifier, type: prov_type, platform: params[:platform]), parsed["Name"]) Utils.fill_environment(Utils.environment_variable_name_profile_path(app_identifier: app_identifier, type: prov_type, platform: params[:platform]), installed_profile) return uuid end # rubocop:enable Metrics/PerceivedComplexity def should_force_include_all_devices(params: nil, prov_type: nil, profile: nil, keychain_path: nil) return false unless params[:force_for_new_devices] && !params[:readonly] force = false prov_types_without_devices = [:appstore, :developer_id] if !prov_types_without_devices.include?(prov_type) && !params[:force] force = device_count_different?(profile: profile, keychain_path: keychain_path, platform: params[:platform].to_sym, include_mac_in_profiles: params[:include_mac_in_profiles]) else # App Store provisioning profiles don't contain device identifiers and # thus shouldn't be renewed if the device count has changed. UI.important("Warning: `force_for_new_devices` is set but is ignored for App Store & Developer ID provisioning profiles.") UI.important("You can safely stop specifying `force_for_new_devices` when running Match for type 'appstore' or 'developer_id'.") end return force end def device_count_different?(profile: nil, keychain_path: nil, platform: nil, include_mac_in_profiles: false) return false unless profile parsed = FastlaneCore::ProvisioningProfile.parse(profile, keychain_path) uuid = parsed["UUID"] all_profiles = Spaceship::ConnectAPI::Profile.all(includes: "devices") portal_profile = all_profiles.detect { |i| i.uuid == uuid } if portal_profile profile_device_count = portal_profile.fetch_all_devices.count device_classes = case platform when :ios [ Spaceship::ConnectAPI::Device::DeviceClass::IPAD, Spaceship::ConnectAPI::Device::DeviceClass::IPHONE, Spaceship::ConnectAPI::Device::DeviceClass::IPOD, Spaceship::ConnectAPI::Device::DeviceClass::APPLE_WATCH ] when :tvos [ Spaceship::ConnectAPI::Device::DeviceClass::APPLE_TV ] when :macos, :catalyst [ Spaceship::ConnectAPI::Device::DeviceClass::MAC ] else [] end if platform == :ios && include_mac_in_profiles device_classes += [Spaceship::ConnectAPI::Device::DeviceClass::APPLE_SILICON_MAC] end devices = Spaceship::ConnectAPI::Device.all unless device_classes.empty? devices = devices.select do |device| device_classes.include?(device.device_class) && device.enabled? end end portal_device_count = devices.size return portal_device_count != profile_device_count end return false end def should_force_include_all_certificates(params: nil, prov_type: nil, profile: nil, keychain_path: nil) unless params[:include_all_certificates] if params[:force_for_new_certificates] UI.important("You specified 'force_for_new_certificates: true', but new certificates will not be added, cause 'include_all_certificates' is 'false'") end return false end force = false if params[:force_for_new_certificates] && !params[:readonly] if prov_type == :development && !params[:force] force = certificate_count_different?(profile: profile, keychain_path: keychain_path, platform: params[:platform].to_sym) else # All other (not development) provisioning profiles don't contain # multiple certificates, thus shouldn't be renewed # if the certificates count has changed. UI.important("Warning: `force_for_new_certificates` is set but is ignored for non-'development' provisioning profiles.") UI.important("You can safely stop specifying `force_for_new_certificates` when running Match for '#{prov_type}' provisioning profiles.") end end return force end def certificate_count_different?(profile: nil, keychain_path: nil, platform: nil) return false unless profile parsed = FastlaneCore::ProvisioningProfile.parse(profile, keychain_path) uuid = parsed["UUID"] all_profiles = Spaceship::ConnectAPI::Profile.all(includes: "certificates") portal_profile = all_profiles.detect { |i| i.uuid == uuid } return false unless portal_profile # When a certificate expires (not revoked) provisioning profile stays valid. # And if we regenerate certificate count will not differ: # * For portal certificates, we filter out the expired one but includes a new certificate; # * Profile still contains an expired certificate and is valid. # Thus, we need to check the validity of profile certificates too. profile_certs_count = portal_profile.fetch_all_certificates.select(&:valid?).count certificate_types = case platform when :ios, :tvos [ Spaceship::ConnectAPI::Certificate::CertificateType::DEVELOPMENT, Spaceship::ConnectAPI::Certificate::CertificateType::IOS_DEVELOPMENT ] when :macos, :catalyst [ Spaceship::ConnectAPI::Certificate::CertificateType::DEVELOPMENT, Spaceship::ConnectAPI::Certificate::CertificateType::MAC_APP_DEVELOPMENT ] else [] end certificates = Spaceship::ConnectAPI::Certificate.all unless certificate_types.empty? certificates = certificates.select do |certificate| certificate_types.include?(certificate.certificateType) && certificate.valid? end end portal_certs_count = certificates.size return portal_certs_count != profile_certs_count end end # rubocop:enable Metrics/ClassLength end