Всем привет. В преддверии старта курсов  "iOS Developer. Basic" и "iOS Developer. Professional", публикуем заключительную часть статьи про интеграцию CI/CD для нескольких сред с Jenkins и Fastlane.

А также приглашаем вас на бесплатный демо-урок по теме: "Combine до iOS 13 и как добавить SwiftUI 2.0 в любое приложение"


Настройка Jenkins под разные среды

В предыдущей части нам удалось создать задачу Jenkins, загружающую наше приложение в Testflight для разных веток под разные фичи. Для достижения цели, намеченной в первой части, остается только реализовать возможность делать это для разных сред, например, для стейджа и тест продакшн среды. Разные среды эквивалентны разным конфигурациям Xcode (т. е. различным пользовательским схемам) с определенными настройками сборки под каждую. Если мы еще раз взглянем на три файла, написанные нами раньше: MyScipt.groovy, Deploy.groovy и Fastfile, мы заметим, что там есть свойства, зависящие от конфигурации, а именно:

  1. Идентификатор приложения

  2. Конфигурация, которая соответствует уникальной схеме, созданной в Xcode

  3. Имя профиля обеспечения (provisioning profile)

Эти свойства используются в разных местах, в основном внутри лейнов Fastlane. Следовательно, для достижения нашей цели мы должны передавать эти свойства в качестве параметров в лейнах и использовать разные значения для каждой конфигурации. Синтаксис лейна с параметрами следующий:

lane :build do |options|

а параметр может быть получен следующим образом:

parameter = options[:parameter_name]

В нашем случае нам нужны три разных параметра, описанных выше.

Теперь предположим, что у нас есть среды под названием Staging и TestProduction. Нам нужно будет реализовать в Jenkins два разных задания, таких же как задание, которое мы создавали ранее, и подключить их к двум разным скриптам, скажем, Stg.groovy и TestProduction.groovy. Эти скрипты будут идентичны, за исключением следующих трех параметров:

Stg-parameters.groovy

// идентификатор приложения
def getBundleId() {
return "com.our_project.stg" 
} 
// название схемы 
def getConfiguration() {
return "Stg-Testflight"
} 
// имя профиля обеспечения 
def getProvisioningProfile() {
 return "\'match AppStore com.our_project.stg'"
}

 // идентификатор приложения
def getBundleId() {
  return "com.our_project.test.production"
 }
 // название схемы
 def getConfiguration() {
  return "TestProduction-Testflight"
 }
 // имя профиля обеспечения
 def getProvisioningProfile() {
  return "\'match AppStore com.our_project.test.production'"
 }

А получать к ним доступ внутри лейна сборки, мы будем следующим образом:

TestProduction-parameters.groovy

lane :build do |options|

bundle_id = options[:bundle_id]
configuration = options[:configuration]
provisioning_profile = options[:provisioning_profile]

.
.
.

end

Функция deploy внутри Deploy.script будет получать эти параметры в сигнатуре метода:

def deployWith(bundle_id, configuration, provisioning_profile) {

.
.
.

}

Стадии, использующие эти параметры: Run Tests, Build и Upload to Testflight - это стадии, требующие параметризации. Остальные (Checkout repo, Install dependencies, Reset simulators и Cleanup в конце) останутся прежними.

Настройка стадии Run Tests

Мы реализуем Run tests, передавая configuration в качестве параметра, чтобы использовать его внутри соответствующего лейна:

stage('Run Tests') {
     sh 'bundle exec fastlane test configuration:$configuration'
}

Теперь самая сложная часть - настроить лейн test внутри Fastfile, чтобы он использовал этот параметр. Получаем параметры следующим образом:

lane :a_lane do |options|
        ....
bundle_id = options[:bundle_id]
configuration = options[:configuration]
provisioning_profile = options[:provisioning_profile]
        ...
end

Итак, лейн test, который должен использовать только параметр configuration, теперь будет выглядеть так:

lane :test do |options|
    configuration = options[:configuration]
      scan(
       clean: true,
        devices: ["iPhone X"],
        workspace: "our_project.xcworkspace",
        scheme: configuration,
        code_coverage: true,
        output_directory: "./test_output",
        output_types: "html,junit"
    )
    slather(
        cobertura_xml: true,
        proj: "our_project.xcodeproj",
        workspace: "our_project.xcworkspace",
        output_directory: "./test_output",
        scheme: configuration,
        jenkins: true,
        ignore: [array_of_docs_to_ignore]
    )
 end

Обратите внимание, что в поле scheme мы используем параметр configuration, поскольку схема разнится от среды к среде.

Настройка стадии Build

Стадии Build необходимы все три параметра. Мы реализуем команду build Fastlane, передавая эти три параметра. Стадия Build, которая теперь внутри функции deployWith(), будет выглядеть следующим образом:

stage('Build') {
   withEnv(["FASTLANE_USER=fastlane_user_email_address"]) {
       withCredentials([
           string([
               credentialsId:'match_password_id',
               variable: 'MATCH_PASSWORD'
                 ]),
           string([
               credentialsId: 'fastlane_password_id',
               variable: 'FASTLANE_PASSWORD']),
                 ]) {
                    sh 'bundle exec fastlane build bundle_id:$bundle_id configuration:$configuration provisioning_profile:$provisioning_profile'
                    }
    }
 }

Это означает, что лейн build останется точно таким же, как и раньше, за исключением команд которые используют эти три параметра:

lane :build do |options|
  
   bundle_id = options[:bundle_id]
   configuration = options[:configuration]
   provisioning_profile = options[:provisioning_profile]
  
   match(
       git_branch: "the_branch_of_the_repo_with_the_prov_profile",
       username: "github_username",
       git_url: "github_repo_with_prov_profiles",
       type: "appstore",
       app_identifier: bundle_id,
       force: true) 
 
   version = get_version_number(
                      xcodeproj: "our_project.xcodeproj",
                      target: "production_target"
               )
 
   build_number = latest_testflight_build_number(
                       version: version,
                       app_identifier: bundle_id,
                       initial_build_number: 0
                       )
 
   increment_build_number({ build_number: build_number + 1 })
 
   settings_to_override = {
     :BUNDLE_IDENTIFIER => bundle_id,
     :PROVISIONING_PROFILE_SPECIFIER => provisioning_profile,
     :DEVELOPMENT_TEAM => "team_id"
    }
 
    export_options = {
      iCloudContainerEnvironment: "Production",
      provisioningProfiles: { bundle_id => provisioning_profile }
    }
 
   gym(
     clean: true,
     scheme: configuration,
     configuration: configuration,
     xcargs: settings_to_override,
     export_method: "app-store",
     include_bitcode: true,
     include_symbols: true,
     export_options: export_options
    )
 end

Легко заметить, что везде, где используются значения bundle_id, configuration и provisioning_profiles, мы теперь используем значения этих параметров вместо захардкоженных.

Настройка стадии Upload To Testflight

Мы используем команду Fastlane upload_to_testflight, передавая ей в качестве параметра bundle_id. Стадия Upload to TestFlight теперь тоже находится внутри функции deployWith() файла Deploy.groovy и будет выглядеть, как показано ниже:

stage('Upload to TestFlight') {
      withEnv(["FASTLANE_USER=fastlane_user_email_address"]) {
       withCredentials([
                string([credentialsId: 'fastlane_password_id', variable: 'FASTLANE_PASSWORD']),
        ]) {
          sh "bundle exec fastlane upload_to_testflight bundle_id:$bundle_id"
        }
      }
 }

Соответствующий лейн внутри FastFile будет теперь использовать bundle_id таким образом:

lane :upload_to_testflight do |options|
    bundle_id = options[:bundle_id]
   pilot(
      ipa: "./build/WorkableApp.ipa",
      skip_submission: true,
      skip_waiting_for_build_processing: true,
      app_identifier: bundle_id
    )
  end

И на этом все! Нам удалось использовать одни и те же Deploy.groovy и Fastfile для различных конфигураций. Но как мы собираемся разделять эти параметры для разных конфигураций?

В Jenkins вместо одной задачи для всех конфигураций мы теперь можем создавать разные задачи под каждую, которые будут идентичными, за исключением скрипта, который они используют. Поэтому, как я упоминал ранее, мы создаем задачу Stg и используем скрипт Stg.groovy, приведенный ниже:

node(label: 'ios') {
 
 
 def deploy;
 def utils;
  
 String RVM = "ruby-2.5.0"
  
 ansiColor('xterm') {
   withEnv(["LANG=en_US.UTF-8", "LANGUAGE=en_US.UTF-8", "LC_ALL=en_US.UTF-8"]) {
 
       deploy = load("jenkins/Deploy.groovy")
       utils = load("jenkins/utils.groovy")
 
 
       utils.withRvm(RVM) {
            deploy.deployWith(getBundleId(), getConfiguration(), getProvisioningProfile())
        }
    }
  }
 }
  
// идентификатор приложения
 def getBundleId() {
  return "com.our_project.stg"
}
//название схемы
def getConfiguration() {
  return "Stg-Testflight"
 }
 // имя профиля обеспечения
 def getProvisioningProfile() {
  return "\'match AppStore com.our_project.stg'"
 }

Точно так же для конфигурации TestProduction, мы создаем новую идентичную задачу и используем скрипт TestProduction.groovy:

node(label: 'ios') {

 def deploy;
 def utils;
  
 String RVM = "ruby-2.5.0"
  
 ansiColor('xterm') {
    withEnv(["LANG=en_US.UTF-8", "LANGUAGE=en_US.UTF-8", "LC_ALL=en_US.UTF-8"]) {
   
       deploy = load("jenkins/Deploy.groovy")
       utils = load("jenkins/utils.groovy")
  
       utils.withRvm(RVM) {
            deploy.deployWith(getBundleId(), getConfiguration(), getProvisioningProfile())
        }
    }
  }
}
 
// идентификатор приложения
def getBundleId() {
 return "com.our_project.test.production"
}
 
// название схемы
 def getConfiguration() {
 return "TestProduction-Testflight"
}
// имя профиля обеспечения
 def getProvisioningProfile() {
  return "\'match AppStore com.our_project.test.production'"
}

Обратите внимание, что все они используют один и тот же скрипт Deploy.groovy и один и тот же файл Fastfile, а именно:

def deployWith(bundle_id, configuration, provisioning_profile) {
 
   stage('Checkout') {
       checkout scm
   }
 
   stage('Install dependencies') {
      sh 'gem install bundler'
      sh 'bundle update'
      sh 'bundle exec pod repo update'
      sh 'bundle exec pod install'
   }
 
   stage('Reset Simulators') {
      sh 'bundle exec fastlane snapshot reset_simulators --force'
   }

  stage('Run Tests') {
     sh 'bundle exec fastlane test configuration:$configuration'
  }

  stage('Build') {
       withEnv(["FASTLANE_USER=fastlane_user_email_address"]) {
           withCredentials([
                string([
                     credentialsId:'match_password_id',
                     variable: 'MATCH_PASSWORD'
                ]),
                string([
                     credentialsId: 'fastlane_password_id',
                     variable: 'FASTLANE_PASSWORD']),
                ]) {
                     sh 'bundle exec fastlane build bundle_id:$bundle_id configuration:$configuration provisioning_profile:$provisioning_profile'
                }
       }
  }

   stage('Upload to TestFlight') {
        withEnv(["FASTLANE_USER=fastlane_user_email_address"]) {
           withCredentials([
                string([
                     credentialsId: 'fastlane_password_id',
                     variable: 'FASTLANE_PASSWORD']),
                 ]) {
                    sh "bundle exec fastlane upload_to_testflight bundle_id:$bundle_id"
                 }
      }
   }

   stage('Cleanup') {
       cleanWs notFailBuild: true
   }
}

Fastfile_parameterized

fastlane_version "2.75.0"
 
default_platform :ios
  
lane :test do |options|
   configuration = options[:configuration]
   scan(
       clean: true,
       devices: ["iPhone X"],
       workspace: "our_project.xcworkspace",
       scheme: configuration,
       code_coverage: true,
       output_directory: "./test_output",
       output_types: "html,junit"
   )
   slather(
       cobertura_xml: true,
       proj: "our_project.xcodeproj",
       workspace: "our_project.xcworkspace",
       output_directory: "./test_output",
       scheme: configuration,
       jenkins: true,
       ignore: [array_of_docs_to_ignore]
   )
end
  
lane :build do |options|
  
   bundle_id = options[:bundle_id]
   configuration = options[:configuration]
   provisioning_profile = options[:provisioning_profile]
  
   match(
       git_branch: "the_branch_of_the_repo_with_the_prov_profile",
       username: "github_username",
       git_url: "github_repo_with_prov_profiles",
       type: "appstore",
       app_identifier: bundle_id,
       force: true)
  
   version = get_version_number(
                      xcodeproj: "our_project.xcodeproj",
                      target: "production_target"
              )
    build_number = latest_testflight_build_number(
                       version: version,
                       app_identifier: bundle_id,
                       initial_build_number: 0
                       )
 
   increment_build_number({ build_number: build_number + 1 })
  
   settings_to_override = {
     :BUNDLE_IDENTIFIER => bundle_id,
     :PROVISIONING_PROFILE_SPECIFIER => provisioning_profile,
     :DEVELOPMENT_TEAM => "team_id"
   }
  
    export_options = {
      iCloudContainerEnvironment: "Production",
      provisioningProfiles: { bundle_id => provisioning_profile }
    }
  
   gym(
     clean: true,
     scheme: configuration,
     configuration: configuration,
     xcargs: settings_to_override,
     export_method: "app-store",
     include_bitcode: true,
     include_symbols: true,
     export_options: export_options
   )
 end
 
lane :upload_to_testflight do |options|
   bundle_id = options[:bundle_id]
   pilot(
      ipa: "./build/WorkableApp.ipa",
      skip_submission: true,
      skip_waiting_for_build_processing: true,
      app_identifier: bundle_id
    )
 end

Мы достигли нашей изначальной цели

Как я упоминал в предыдущем разделе, нашей изначальной целью было реализовать автоматическую загрузку билдов по нажатию одной кнопки в Testflight:

a) для разных веток под разные фичи

b) для нескольких конфигураций, соответствующих разным средам

Нам удалось достичь вышеупомянутое с помощью разных задач в Jenkins, которые могут быть настроены под разные ветки. Каждая задача соответствует своей конкретной конфигурации Xcode и, в добавку, своей конкретной среде. Все они используют одни и те же 2 основных файла (Deploy.groovyи Fastlane) и разные исходные файлы скриптов (Stg.groovy и TestProduction.groovy).

Конечно, вы можете распространить их сразу на несколько сред с помощью простого скрипта величиной всего в несколько строчек кода!

Спасибо, за ваше внимание ?, я надеюсь, что эта серия оказалась для вас полезной ?!

Особая благодарность Павлосу-Петросу Турнарису и Джорджу Цифрикасу за их ценные отзывы.

Twitter: @elenipapanikolo


Узнать подробнее о курсах:

- iOS Developer. Basic"

- iOS Developer. Professional


Читать ещё: