diff --git a/README.md b/README.md index 0f05627..8efb051 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ Special thanks to [Matthias Tretter](https://twitter.com/myell0w) for coming up
sigh is part of fastlane: connect all deployment tools into one streamlined workflow.
+### spaceship version + +If you're feeling adventurous and want to test the new `sigh` beta with [spaceship](https://spaceship.airforce), update using `sudo gem update sigh --pre`. More information in the [release notes](https://github.com/KrauseFx/sigh/releases/tag/1.0.0.beta5). # Features @@ -175,6 +178,8 @@ Choose signing certificate to use: - `SIGH_CERTIFICATE_ID` (The ID of the certificate) - `SIGH_CERTIFICATE_EXPIRE_DATE` (The expire date of the certificate) +As always, run `sigh --help` to get a list of all variables. + If you're using [cert](https://github.com/KrauseFx/cert) in combination with [fastlane](https://github.com/KrauseFx/fastlane) the signing certificate will automatically be selected for you. (make sure to run `cert` before `sigh`) `sigh` will store the `UDID` of the generated provisioning profile in the environment: `SIGH_UDID`. diff --git a/lib/sigh.rb b/lib/sigh.rb index 149abe5..7a1923b 100644 --- a/lib/sigh.rb +++ b/lib/sigh.rb @@ -1,6 +1,5 @@ require 'sigh/version' require 'sigh/dependency_checker' -require 'sigh/developer_center' require 'sigh/resign' require 'fastlane_core' diff --git a/lib/sigh/developer_center.rb b/lib/sigh/developer_center.rb deleted file mode 100644 index f1a5620..0000000 --- a/lib/sigh/developer_center.rb +++ /dev/null @@ -1,292 +0,0 @@ -require 'fastlane_core/developer_center/developer_center' -require 'sigh/developer_center_signing' - -module Sigh - class DeveloperCenter < FastlaneCore::DeveloperCenter - # Types of certificates - APPSTORE = "AppStore" - ADHOC = "AdHoc" - DEVELOPMENT = "Development" - - PROFILES_URL_DEV = "https://developer.apple.com/account/ios/profile/profileList.action?type=limited" - - def run - @type = Sigh::DeveloperCenter::APPSTORE - @type = Sigh::DeveloperCenter::ADHOC if Sigh.config[:adhoc] - @type = Sigh::DeveloperCenter::DEVELOPMENT if Sigh.config[:development] - - cert = maintain_app_certificate # create/download the certificate - - if @type == APPSTORE # both enterprise and App Store - type_name = "Distribution" - elsif @type == ADHOC - type_name = "AdHoc" - else - type_name = "Development" - end - cert_name ||= "#{type_name}_#{Sigh.config[:app_identifier]}.mobileprovision" # default name - cert_name += '.mobileprovision' unless cert_name.include?'mobileprovision' - - output_path = File.join(TMP_FOLDER, cert_name) - File.open(output_path, "wb") do |f| - f.write(cert) - end - - store_provisioning_id_in_environment(output_path) - - return output_path - end - - def store_provisioning_id_in_environment(path) - require 'sigh/profile_analyser' - udid = Sigh::ProfileAnalyser.run(path) - ENV["SIGH_UDID"] = udid if udid - end - - def maintain_app_certificate(force = nil) - force = Sigh.config[:force] if (force == nil) - begin - if @type == DEVELOPMENT - visit PROFILES_URL_DEV - else - visit PROFILES_URL - end - - @list_certs_url = wait_for_variable('profileDataURL') - # list_certs_url will look like this: "https://developer.apple.com/services-account/..../account/ios/profile/listProvisioningProfiles.action?content-type=application/x-www-form-urlencoded&accept=application/json&requestId=id&userLocale=en_US&teamId=xy&includeInactiveProfiles=true&onlyCountLists=true" - Helper.log.info "Fetching all available provisioning profiles..." - - has_all_profiles = false - page_index = 1 - page_size = 500 - - until has_all_profiles do - bundle_id = Sigh.config[:app_identifier] - - certs = post_ajax(@list_certs_url, "{pageNumber: #{page_index}, pageSize: #{page_size}, sort: 'name%3dasc', search: ''}") - - if certs - profile_name = Sigh.config[:provisioning_name] - - profile_count = certs['provisioningProfiles'].count - - Helper.log.info "Checking if profile is available. (#{profile_count} profiles found on page #{page_index})" - required_cert_types = (@type == DEVELOPMENT ? ['iOS Development'] : ['iOS Distribution', 'iOS UniversalDistribution']) - certs['provisioningProfiles'].each do |current_cert| - next unless required_cert_types.include?(current_cert['type']) - - details = profile_details(current_cert['provisioningProfileId']) - - if details['provisioningProfile']['appId']['identifier'] == bundle_id - - next if profile_name && details['provisioningProfile']['name'] != profile_name - - # that's an Ad Hoc profile. I didn't find a better way to detect if it's one ... skipping it - next if @type == APPSTORE && details['provisioningProfile']['deviceCount'] > 0 - - # that's an App Store profile ... skipping it - next if @type != APPSTORE && details['provisioningProfile']['deviceCount'] == 0 - - # We found the correct certificate - if force - renew_profile(current_cert['provisioningProfileId']) # This one needs to be forcefully renewed - return maintain_app_certificate(false) # recursive - elsif current_cert['status'] == 'Active' - return download_profile(details['provisioningProfile']['provisioningProfileId']) # this one is already finished. Just download it. - elsif ['Expired', 'Invalid'].include? current_cert['status'] - # Broken profile - begin - renew_profile(current_cert['provisioningProfileId']) # This one needs to be renewed - return maintain_app_certificate(false) # recursive - rescue - # Something went wrong, just create a new one instead - end - end - - break - end - end - - if page_size <= profile_count - page_index += 1 - else - has_all_profiles = true - end - end - end - - Helper.log.info "Could not find existing profile. Trying to create a new one." - # Certificate does not exist yet, we need to create a new one - create_profile - # After creating the profile, we need to download it - return maintain_app_certificate(false) # recursive - - rescue => ex - error_occured(ex) - end - end - - def create_profile - app_identifier = Sigh.config[:app_identifier] - Helper.log.info "Creating new profile for app '#{app_identifier}' for type '#{@type}'.".yellow - certificates = code_signing_certificates(@type) - - create_url = "https://developer.apple.com/account/ios/profile/profileCreate.action" - visit create_url - - # 1) Select the profile type (AppStore, Adhoc) - enterprise = false - - begin - wait_for_elements('#type-production') - rescue => ex - wait_for_elements('#type-inhouse') # enterprise accounts - enterprise = true - end - - value = enterprise ? 'inhouse' : 'store' - value = 'limited' if @type == DEVELOPMENT - value = 'adhoc' if @type == ADHOC - - first(:xpath, "//input[@type='radio' and @value='#{value}']").click - click_next - - # 2) Select the App ID - sleep 1 while !page.has_content? "Select App ID" - # example: - identifiers = all(:xpath, "//option[contains(text(), '.#{app_identifier})')]") - if identifiers.count == 0 - puts "Couldn't find App ID '#{app_identifier}'\nonly found the following bundle identifiers:".red - all(:xpath, "//option").each do |current| - puts "\t- #{current.text}".yellow - end - raise "Could not find App ID '#{app_identifier}'.".red - else - identifiers.first.select_option - end - click_next - - # 3) Select the certificate - sleep 1 while !page.has_content? "Select certificates" - sleep 3 - Helper.log.info "Using certificates: #{certificates.map { |c| "#{c['ownerName']} (#{c['certificateId']})" } }" - - # example: (production) - - clicked = false - certificates.each do |cert| - cert_id = cert['certificateId'] - input = if @type == DEVELOPMENT - # development uses a checkbox and has no [] around the value - first(:xpath, "//input[@type='checkbox' and @value='#{cert_id}']") - else - break if clicked - # production uses radio and has a [] around the value - first(:xpath, "//input[@type='radio' and @value='[#{cert_id}]']") - end - if input - input.click - clicked = true - end - end - - if !clicked - raise "Could not find certificate in the list of available certificates." - end - click_next - - if @type != APPSTORE - # 4) Devices selection - wait_for_elements('.selectAll.column') - sleep 3 - - first(:xpath, "//div[@class='selectAll column']/input").click # select all the devices - click_next - end - - # 5) Choose a profile name - wait_for_elements('.distributionType') - profile_name = Sigh.config[:provisioning_name] - profile_name ||= [app_identifier, @type].join(' ') - fill_in "provisioningProfileName", with: profile_name - click_next - - if page.has_content?"Multiple profiles found with the name" - fill_in "provisioningProfileName", with: (profile_name + " sigh") - click_next - end - - wait_for_elements('.row-details') - end - - def renew_profile(profile_id) - certificate = code_signing_certificates(@type).first - - details_url = "https://developer.apple.com/account/ios/profile/profileEdit.action?type=&provisioningProfileId=#{profile_id}" - Helper.log.info "Renewing provisioning profile '#{profile_id}' using URL '#{details_url}'" - visit details_url - - Helper.log.info "Using certificate ID '#{certificate['certificateId']}' from '#{certificate['ownerName']}'" - wait_for_elements('.selectCertificates') - - certs = all(:xpath, "//input[@type='radio' and @value='#{certificate["certificateId"]}']") - if certs.count == 1 - certs.first.click - - if @type != APPSTORE - # Add all devices - wait_for_elements('.selectAll.column') - sleep 3 - unless first(:xpath, "//div[@class='selectAll column']/input")["checked"] - first(:xpath, "//div[@class='selectAll column']/input").click # select all the devices - end - end - - click_next - - wait_for_elements('.row-details') - click_on "Done" - else - if @type != APPSTORE - # Add all devices - wait_for_elements('.selectAll.column') - sleep 3 - unless all(:xpath, "//div[@class='selectAll column']/input").last["checked"] - all(:xpath, "//div[@class='selectAll column']/input").last.click # select all the devices - end - click_next - - wait_for_elements('.row-details') - click_on "Done" - else - Helper.log.info "Looking for certificate: #{certificate}." - raise "Could not find certificate in the list of available certificates." - end - end - end - - def download_profile(profile_id) - download_cert_url = "/account/ios/profile/profileContentDownload.action?displayId=#{profile_id}" - - return download_file(download_cert_url) - end - - - private - def profile_details(profile_id) - # We need to build the URL to get the App ID for a specific certificate - current_profile_url = @list_certs_url.gsub('listProvisioningProfiles', 'getProvisioningProfile') - current_profile_url += "&provisioningProfileId=#{profile_id}" - # Helper.log.debug "Fetching URL: '#{current_profile_url}'" - - result = post_ajax(current_profile_url) - # Example response, see bottom of file - - if result['resultCode'] == 0 - return result - else - raise "Error fetching details for provisioning profile '#{profile_id}'".red - end - end - end -end diff --git a/lib/sigh/developer_center_signing.rb b/lib/sigh/developer_center_signing.rb deleted file mode 100644 index abc3067..0000000 --- a/lib/sigh/developer_center_signing.rb +++ /dev/null @@ -1,105 +0,0 @@ -module Sigh - class DeveloperCenter < FastlaneCore::DeveloperCenter - # Returns a array of hashes, that contains information about the iOS certificate - # @example - # [{"certRequestId"=>"B23Q2P396B", - # "name"=>"SunApps GmbH", - # "statusString"=>"Issued", - # "expirationDate"=>"2015-11-25T22:45:50Z", - # "expirationDateString"=>"Nov 25, 2015", - # "ownerType"=>"team", - # "ownerName"=>"SunApps GmbH", - # "ownerId"=>"....", - # "canDownload"=>true, - # "canRevoke"=>true, - # "certificateId"=>"....", - # "certificateStatusCode"=>0, - # "certRequestStatusCode"=>4, - # "certificateTypeDisplayId"=>"...", - # "serialNum"=>"....", - # "typeString"=>"iOS Distribution"}, - # {another sertificate...}] - def code_signing_certificates(type) - certs_url = "https://developer.apple.com/account/ios/certificate/certificateList.action?type=" - certs_url << (type == DEVELOPMENT ? 'development' : 'distribution') - visit certs_url - - certificateDataURL = wait_for_variable('certificateDataURL') - certificateRequestTypes = wait_for_variable('certificateRequestTypes') - certificateStatuses = wait_for_variable('certificateStatuses') - - # Setup search criteria - certificate_name = Sigh.config[:cert_owner_name] - cert_date = Sigh.config[:cert_date] - cert_id = Sigh.config[:cert_id] - - # The other profiles are push profiles - certificate_type = type == DEVELOPMENT ? 'iOS Development' : 'iOS Distribution' - - url = [certificateDataURL, certificateRequestTypes, certificateStatuses].join('') - # https://developer.apple.com/services-account/.../account/ios/certificate/listCertRequests.action?content-type=application/x-www-form-urlencoded&accept=application/json&requestId=...&userLocale=en_US&teamId=...&types=...&status=4&certificateStatus=0&type=distribution - - has_all_certificates = false - page_index = 1 - page_size = 500 - - matched_certificates = [] - - until has_all_certificates do - certs = post_ajax(url, "{pageNumber: #{page_index}, pageSize: #{page_size}, sort: 'name%3dasc'}")['certRequests'] - - if certs - certificates_count = certs.count - - Helper.log.info "Attempting to locate certificate. (#{certificates_count} certificates found on page #{page_index})" - - # New profiles first - certs.sort! do |a, b| - Time.parse(b['expirationDate']) <=> Time.parse(a['expirationDate']) - end - - certs.each do |current_cert| - next unless current_cert['typeString'] == certificate_type - - if cert_date || certificate_name || cert_id - if current_cert['expirationDateString'] == cert_date - Helper.log.info "Certificate ID '#{current_cert['certificateId']}' with expiry date '#{current_cert['expirationDateString']}' located".green - matched_certificates << current_cert - end - - if current_cert['name'] == certificate_name - Helper.log.info "Certificate ID '#{current_cert['certificateId']}' with name '#{certificate_name}' located".green - matched_certificates << current_cert - end - - if current_cert['certificateId'] == cert_id - Helper.log.info "Certificate ID '#{current_cert['certificateId']}' with name '#{current_cert['name']}' located".green - matched_certificates << current_cert - end - else - matched_certificates << current_cert - end - end - end - - if page_size <= certificates_count - page_index += 1 - else - has_all_certificates = true - end - end - - return matched_certificates unless matched_certificates.empty? - - predicates = [] - predicates << "name: #{certificate_name}" if certificate_name - predicates << "expiry date: #{cert_date}" if cert_date - predicates << "certificate ID: #{cert_id}" if cert_id - predicates << "type: #{(type == DEVELOPMENT ? 'development' : 'distribution')}" - - predicates_str = " with #{predicates.join(', ')}" - - raise "Could not find a Certificate#{predicates_str}. Please open #{current_url} and make sure you have a signing profile created, which matches the given filters".red - end - end -end diff --git a/lib/sigh/manager.rb b/lib/sigh/manager.rb index 2fe89ef..530543f 100644 --- a/lib/sigh/manager.rb +++ b/lib/sigh/manager.rb @@ -1,9 +1,11 @@ require 'plist' +require 'sigh/spaceship/runner' module Sigh class Manager def self.start - path = Sigh::DeveloperCenter.new.run + start = Time.now + path = Sigh::Runner.new.run return nil unless path @@ -38,7 +40,7 @@ def self.install_profile(profile) (FileUtils.copy profile, destination rescue nil) # if the directory doesn't exist yet if File.exists? destination - Helper.log.info "Profile installed at \"#{destination}\"" + Helper.log.info "Profile successfully installed".green else raise "Failed installation of provisioning profile at location: #{destination}".red end diff --git a/lib/sigh/options.rb b/lib/sigh/options.rb index 27ad958..b25baed 100644 --- a/lib/sigh/options.rb +++ b/lib/sigh/options.rb @@ -1,4 +1,5 @@ require 'fastlane_core' +require 'credentials_manager' module Sigh class Options @@ -22,7 +23,7 @@ def self.available_options default_value: false), FastlaneCore::ConfigItem.new(key: :force, env_name: "SIGH_FORCE", - description: "Renew non-development provisioning profiles regardless of its state", + description: "Renew provisioning profiles regardless of its state", is_string: false, default_value: false), FastlaneCore::ConfigItem.new(key: :app_identifier, @@ -34,7 +35,7 @@ def self.available_options short_option: "-u", env_name: "SIGH_USERNAME", description: "Your Apple ID Username", - default_value: CredentialsManager::AppfileConfig.try_fetch_value(:apple_id), + default_value: ENV["DELIVER_USER"] || CredentialsManager::AppfileConfig.try_fetch_value(:apple_id), verify_block: Proc.new do |value| CredentialsManager::PasswordManager.shared_manager(value) end), @@ -43,9 +44,19 @@ def self.available_options env_name: "SIGH_TEAM_ID", description: "The ID of your team if you're in multiple teams", optional: true, + default_value: CredentialsManager::AppfileConfig.try_fetch_value(:team_id), verify_block: Proc.new do |value| ENV["FASTLANE_TEAM_ID"] = value end), + FastlaneCore::ConfigItem.new(key: :team_name, + short_option: "-l", + env_name: "SIGH_TEAM_NAME", + description: "The name of your team if you're in multiple teams", + optional: true, + default_value: CredentialsManager::AppfileConfig.try_fetch_value(:team_name), + verify_block: Proc.new do |value| + ENV["FASTLANE_TEAM_NAME"] = value + end), FastlaneCore::ConfigItem.new(key: :provisioning_name, short_option: "-n", env_name: "SIGH_PROVISIONING_PROFILE_NAME", @@ -62,7 +73,7 @@ def self.available_options FastlaneCore::ConfigItem.new(key: :cert_id, short_option: "-i", env_name: "SIGH_CERTIFICATE_ID", - description: "The ID of the certificate to use", + description: "The ID of the code signing certificate to use (e.g. 78ADL6LVAA) ", optional: true), FastlaneCore::ConfigItem.new(key: :cert_owner_name, short_option: "-c", @@ -82,6 +93,7 @@ def self.available_options verify_block: Proc.new do |value| raise "The output name must end with .mobileprovision".red unless value.end_with?".mobileprovision" end) + ] end end diff --git a/lib/sigh/profile_analyser.rb b/lib/sigh/profile_analyser.rb index 03c9280..ae94207 100644 --- a/lib/sigh/profile_analyser.rb +++ b/lib/sigh/profile_analyser.rb @@ -5,7 +5,7 @@ class ProfileAnalyser def self.run(path) plist = Plist::parse_xml(`security cms -D -i '#{path}'`) if plist.count > 10 - Helper.log.info("Provisioning profile of app '#{plist['AppIDName']}' with the name '#{plist['Name']}' successfully generated and analysed.".green) + Helper.log.info("Provisioning profile of app '#{plist['AppIDName']}' with the name '#{plist['Name']}' successfully analysed.".green) return plist["UUID"] else Helper.log.error("Error parsing provisioning profile at path '#{path}'".red) diff --git a/lib/sigh/spaceship/runner.rb b/lib/sigh/spaceship/runner.rb new file mode 100644 index 0000000..3c238fb --- /dev/null +++ b/lib/sigh/spaceship/runner.rb @@ -0,0 +1,144 @@ +require 'spaceship' + +module Sigh + class Runner + attr_accessor :spaceship + + # Uses the spaceship to create or download a provisioning profile + # returns the path the newly created provisioning profile (in /tmp usually) + def run + Helper.log.info "Starting login" + Spaceship.login(Sigh.config[:username], nil) + Spaceship.select_team + Helper.log.info "Successfully logged in" + + profiles = fetch_profiles # download the profile if it's there + + if profiles.count > 0 + Helper.log.info "Found #{profiles.count} matching profile(s)".yellow + profile = profiles.first + + if Sigh.config[:force] + unless profile_type == Spaceship.provisioning_profile::AppStore + Helper.log.info "Updating the profile to include all devices".yellow + profile.devices = Spaceship.device.all + else + Helper.log.info "Updating the provisioning profile".yellow + end + + profile = profile.update! # assign it, as it's a new profile + end + else + Helper.log.info "No existing profiles found, creating a new one for you".yellow + profile = create_profile! + end + + raise "Something went wrong fetching the latest profile".red unless profile + + path = download_profile(profile) + store_provisioning_id_in_environment(path) + + return path + end + + # The kind of provisioning profile we're interested in + def profile_type + return @profile_type if @profile_type + + @profile_type = Spaceship.provisioning_profile.app_store + @profile_type = Spaceship.provisioning_profile.ad_hoc if Sigh.config[:adhoc] + @profile_type = Spaceship.provisioning_profile.development if Sigh.config[:development] + @profile_type = Spaceship.provisioning_profile.in_house if Spaceship.client.in_house? + + @profile_type + end + + # Fetches a profile matching the user's search requirements + def fetch_profiles + profile_type.find_by_bundle_id(Sigh.config[:app_identifier]) + end + + # Create a new profile and return it + def create_profile! + cert = certificate_to_use + bundle_id = Sigh.config[:app_identifier] + name = Sigh.config[:provisioning_name] || [bundle_id, profile_type.pretty_type].join(' ') + + if Spaceship.provisioning_profile.all.find { |p| p.name == name } + Helper.log.error "The name '#{name}' is already taken, using another one." + name += " #{Time.now.to_i}" + end + + Helper.log.info "Creating new provisioning profile for '#{Sigh.config[:app_identifier]}' with name '#{name}'".yellow + profile = profile_type.create!(name: name, + bundle_id: bundle_id, + certificate: cert) + profile + end + + + # Certificate to use based on the current distribution mode + def certificate_to_use + if profile_type == Spaceship.provisioning_profile.Development + certificates = Spaceship.certificate.development.all + elsif profile_type == Spaceship.provisioning_profile.InHouse + certificates = Spaceship.certificate.in_house.all + else + certificates = Spaceship.certificate.production.all # Ad hoc or App Store + end + + # Filter them + certificates = certificates.find_all do |c| + if Sigh.config[:cert_id] + next unless (c.id == Sigh.config[:cert_id].strip) + end + + if Sigh.config[:cert_owner_name] + next unless (c.owner_name.strip == Sigh.config[:cert_owner_name].strip) + end + + true + end + + if certificates.count > 1 + Helper.log.info "Found more than one code signing identity. Choosing the first one. Check out `sigh --help` to see all available options.".yellow + Helper.log.info "Available Code Signing Identities for current filters:".green + certificates.each do |c| + Helper.log.info ("\t- Name: " + c.owner_name + " - ID: " + c.id + " - Expires: " + c.expires.strftime("%d/%m/%Y")).green + end + end + + if certificates.count == 0 + filters = "" + filters << "Owner Name: '#{Sigh.config[:cert_owner_name]}' " if Sigh.config[:cert_owner_name] + filters << "Certificate ID: '#{Sigh.config[:cert_id]}' " if Sigh.config[:cert_id] + Helper.log.info "No certificates for filter: #{filters}".yellow if filters.length > 0 + raise "Could not find a matching code signing identity for #{profile_type}. You can use cert to generate one (https://github.com/fastlane/cert)".red + end + + return certificates.first + end + + # Downloads and stores the provisioning profile + def download_profile(profile) + Helper.log.info "Downloading provisioning profile...".yellow + profile_name ||= "#{profile.class.pretty_type}_#{Sigh.config[:app_identifier]}.mobileprovision" # default name + profile_name += '.mobileprovision' unless profile_name.include?'mobileprovision' + + output_path = File.join('/tmp', profile_name) + dataWritten = File.open(output_path, "wb") do |f| + f.write(profile.download) + end + + Helper.log.info "Successfully downloaded provisioning profile...".green + return output_path + end + + # Store the profile ID into the environment + def store_provisioning_id_in_environment(path) + require 'sigh/profile_analyser' + udid = Sigh::ProfileAnalyser.run(path) + ENV["SIGH_UDID"] = udid if udid + end + end +end diff --git a/lib/sigh/version.rb b/lib/sigh/version.rb index f4d1e4b..1703e95 100644 --- a/lib/sigh/version.rb +++ b/lib/sigh/version.rb @@ -1,3 +1,3 @@ module Sigh - VERSION = "0.5.2" + VERSION = "0.6.0" end diff --git a/sigh.gemspec b/sigh.gemspec index 0b65b3f..ab9f2f4 100644 --- a/sigh.gemspec +++ b/sigh.gemspec @@ -21,8 +21,9 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ["lib"] - spec.add_dependency 'fastlane_core', '>= 0.7.2' # all shared code and dependencies + spec.add_dependency 'fastlane_core', '>= 0.7.6' # all shared code and dependencies spec.add_dependency 'plist', '~> 3.1.0' # for reading the provisioning profile + spec.add_dependency 'spaceship', '>= 0.0.6' # communication with Apple # Development only spec.add_development_dependency 'bundler'