CI / CD integration for multiple environments with Jenkins and Fastlane. Part 2

On the eve of the start of the course “iOS Developer. Basic” we continue to publish a series of useful translations, and also invite you to sign up for free demo lesson on the topic: “Result Type”


Read the first part


5. Build assembly

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'
                    }
        }
  }

In this step, we are setting the required environment variables using the Jenkins Pipeline function withEnv as noted here, according to Fastlane documentation. So, we set the environment variable FASTLANE_USERAfter that, we set two more environment variables, MATCH_PASSWORD and FASTLANE_PASSWORD, which cannot be retrieved without credentials. For obvious reasons, they are stored encrypted inside Jenkins in the “Credential” menu item of the Jenkins dashboard in the secret_text format, and they can be obtained by providing the credentialsId.

Again, at the build stage, we implemented a custom lane inside Fastfile, which will be the most complex lane that we will need to create as follows:

lane :build do
     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: "production_app_identifier", 
        force: true)
  
     version = get_version_number(
                       xcodeproj: "our_project.xcodeproj", 
                       target: "production_target"
               )
     build_number = latest_testflight_build_number(
                       version: version,   
                       app_identifier: "production_app_identifier",
                       initial_build_number: 0
                     )
    
     increment_build_number({ build_number: build_number + 1 })
     settings_to_override = {
      :BUNDLE_IDENTIFIER => "production_bundle_id",
      :PROVISIONING_PROFILE_SPECIFIER => "production_prov_profile",
      :DEVELOPMENT_TEAM => "team_id"
     }
   
     export_options = {
       iCloudContainerEnvironment: "Production",
       provisioningProfiles: { "production_bundle_id": "production_prov_profile" }
     }
    
     gym(
       clean: true,
       scheme: "production_scheme",
       configuration: "production_configuration",
       xcargs: settings_to_override,
       export_method: "app-store",
       include_bitcode: true,
       include_symbols: true,
       export_options: export_options
     )
  end

Now let’s break it down step by step.

We start by using the Fastlane action match… Match essentially creates all the required certificates and provisioning profiles, which are stored in a separate git repository, i.e. it essentially automates the code signing process. This means that before running match, we had to create another Github repository where we would store our provisioning profiles. Alternatively, if we don’t want to use match for code signing, we can use actions sigh and cert

Now comes the fun part. What we would like to automate is increasing the build version number for the same release, so as not to have to do it manually every time through the Xcode build settings. We all know that in order to load a build into Testflight several times for the same release and not automatically catch an error, we have to raise the build version every time, i.e. we have to go to project settings or. plist, do it manually and then try re-uploading it. In the code above, we were able to automate this procedure by following the following 3 steps:

  1. get_version_number: Get the version of the project currently loaded

  2. latest_testflight_build_number: Get the current build number for the version we got in the previous step

  3. increment_build_number: The increment of the build number with the specified step (here by one).

Finally, we continue by calling the action gymwhich actually builds and packages the application. It is configurable with several arguments like configuration, scheme and xcargs where we can specify bundle_identifier, export_options etc.

6. Upload to Testflight

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"
       }
  }
}

Here we re-specify the required environment variables as we did in the previous step, and we will implement another custom lane inside Fastfile as follows:

lane :upload_to_testflight do
    pilot(
      ipa: "./build/our_project.ipa",
      skip_submission: true,
      skip_waiting_for_build_processing: true,
      app_identifier: "production_app_identifier"
    )
end

We use Fastlane team pilotwhich loads the .ipa file generated in the previous step into Testflight. With this action, we can also indicate the change log. We can also skip uploading the binary, which means the file .ipa will only be downloaded, but not distributed to testers.

7. Cleaning

Last but not least, in this step we are cleaning up the workspace using the Jenkins plugin cleanup

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

This means that we delete the workspace at the end of the build, as it is no longer needed.

To summarize, here’s how the function deploy() looks like inside the generated script Deploy.script

def deploy() {

    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'
    }
    
    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'
                 }
        }
    }
    
    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"
                 }
       }
    }
    
    stage('Cleanup') {
        cleanWs notFailBuild: true
    }
}

Now our Fastfile looks like this:

fastlane_version "2.75.0"

default_platform :ios

lane :test do
    scan(
        clean: true,
        devices: ["iPhone X"],
        workspace: "our_project.xcworkspace",
        scheme: "production_scheme",
        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: "production_scheme",
        jenkins: true,
        ignore: [array_of_docs_to_ignore]
    )
end

lane :build do
     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: "production_app_identifier", 
        force: true)
  
     version = get_version_number(
                       xcodeproj: "our_project.xcodeproj", 
                       target: "production_target"
               )
     build_number = latest_testflight_build_number(
                       version: version,   
                       app_identifier: "production_app_identifier",
                       initial_build_number: 0
                     )
    
     increment_build_number({ build_number: build_number + 1 })
    
     settings_to_override = {
      :BUNDLE_IDENTIFIER => "production_bundle_id",
      :PROVISIONING_PROFILE_SPECIFIER => "production_prov_profile",
      :DEVELOPMENT_TEAM => "team_id"
     }
    
     export_options = {
       iCloudContainerEnvironment: "Production",
       provisioningProfiles: { "production_bundle_id": "production_prov_profile" }
     }
   
     gym(
       clean: true,
       scheme: "production_scheme",
       configuration: "production_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
    pilot(
      ipa: "./build/our_project.ipa",
      skip_submission: true,
      skip_waiting_for_build_processing: true,
      app_identifier: "production_app_identifier"
    )
end

Function deploy() called from the script that we have defined in the task – MyScript.groovy, and looks like this:

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.deploy()
        }
    } 
  }
}

We load the Deploy.groovy script and call the deploy () function, which does all the work. Here we can notice that we are also loading the script utils.groovywhich helps us to set some environment variables before running Jenkins.job. AnsiColor another Jenkins plugin that is used to color the output of stages in the build pipeline. Finally, we can notice that we are running the script inside

node(label: 'ios')

IN Scripted Pipeline the above node is an important first step as it allocates the executor and workspace for the pipeline.

And so we managed to create a Jenkins task that propagates the various branches of our application, defining a branch as a parameter in Jenkins.

Part 3

IN next part we’ll look at how Jenkins can be configured to distribute our application to different environments for different Xcode configurations.


Sign up for a free demo lesson.


Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *