背景

需要在一台全新的独立mac设备上进行CI,这台设备命名为 Argo。平时大家开发使用Xcode,开启Automatically manage signing,自动构建使用Jenins+fastlane+match在Argo上进行。自动构建的Debug版本放在内部的http服务器上供大家使用xcode安装,自动构建的Relese版本通过fastlane自动上传到testflight,供测试。

原理

jenkins自动构建ios及android架构
jenkins自动构建ios及android架构图

xcode+fastlane+jenkins工作图
xcode+fastlane+jenkins工作图

准备工作

  • Argo上的环境准备:
  • XCode 9.2 + Command Line Tools
  • rbenv + ruby 2.5.0 + gem + bundle
  • XCode中添加apple账号,参见 第一步 ,至关重要!

安装fastlane + match

注意事项:最好使用 match nuke development 和 match nuke distribution 命令把已有的profile都清理掉,再用match生成

授权fastlane

$ fastlane fastlane-credentials add --username xxxx@126.com

编写fastlane

根据构建需求,可以参考fastlane官网

$ cat fastlane/Fastfile
# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
#     https://docs.fastlane.tools/actions
#

# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane

default_platform(:ios)

platform :ios do
    before_all do
        # ENV["SLACK_URL"] = "https://hooks.slack.com/services/..."
        bundle_install
        cocoapods(repo_update: true)
    end

    desc "Push a new alpha build ipa"
    lane :alpha do |options|
        if options[:testmodel] == 'monkey'
          test_MonkeyTest()
        else
	       test_UITests()
        end
    end

    desc "Push a new beta build to TestFlight"
    lane :beta do |options|
        reunpack_all_staticFrameword()

        filename = "doufu_tf_release.ipa"
        register_devices(devices_file: "devices.txt")
        test_UITests()
        build_doufu(
            match_type: "appstore",
            new_device: true,
            export_method: "app-store",
            provisioning_type: "AppStore",
            build_config: "Release",
            filename: "#{filename}"
        )
        upload_to_testflight(
            ipa: "build/#{filename}",
            skip_waiting_for_build_processing: true
        )
    end

    desc "Push a new release build to AppStore"
    lane :release do
        #capture_screenshots                    # generate new screenshots for the App Store
        filename = "doufu_appstore_release.ipa"
        register_devices(devices_file: "devices.txt")
        build_doufu(
            match_type: "appstore",
            new_device: true,
            export_method: "app-store",
            provisioning_type: "AppStore",
            build_config: "Release",
            filename: "#{filename}"
        )
        upload_to_app_store(                      # upload your app to iTunes Connect
            ipa: "build/#{filename}"
        )
        #slack(message: "Successfully uploaded a new App Store build")
    end

    desc "common lane for build, options include:"
    desc ":match_type, profile type, include appstore, development, adhoc, enterprise"
    desc ":new_device, true or false"
    desc ":export_method, archive method, include app-store, development, ad-hoc, package, enterprise, developer-id"
    desc ":provisioning_type, include Development, AppStore, AdHoc"
    desc ":build_config, include Release, Debug"
    desc ":filename, output ipa filename"
    private_lane :build_doufu do |options|
        #disable_automatic_code_signing(path: "SpokenEvaluation.xcodeproj")
        match(
            type: options[:match_type],
            force_for_new_devices: options[:new_device]
        )
        get_push_certificate
        increment_build_number
        remove_bundle_executable(plist_file: "../Pods/AliyunPlayer_iOS/AliyunImageSource.bundle/Info.plist")
        remove_bundle_executable(plist_file: "../Pods/AliyunPlayer_iOS/AliyunLanguageSource.bundle/Info.plist")
        remove_duplicate_gcdasyncudpsocket
        build_app(
            export_method: options[:export_method],
            export_options: {
                provisioningProfiles: {
                    "com.xxxx.xxx" => "match #{options[:provisioning_type]} com.xxxx.xxx"
                }
            },
            export_xcargs: "-allowProvisioningUpdates",
            workspace: "SpokenEvaluation.xcworkspace",
            configuration: options[:build_config],
            scheme: "SpokenEvaluation",
            clean: true,
            output_directory: "build",
            output_name: "#{options[:filename]}"
        )
        #enable_automatic_code_signing(path: "SpokenEvaluation.xcodeproj")
    end

    #自动解决阿里云的bundle的bug
    private_lane :remove_bundle_executable do |options|
        fastlane_require 'cfpropertylist'
        plist = CFPropertyList::List.new(:file => options[:plist_file])
        data = CFPropertyList.native_types(plist.value)
        data.reject! {|key| key == 'CFBundleExecutable'}
        plist.value = CFPropertyList.guess(data)
        plist.save
    end

    #自动解决一些组件文件重复问题
    private_lane :remove_duplicate_gcdasyncudpsocket do
        sh("/bin/rm -rf ../Pods/CocoaAsyncSocket/Source/GCD/GCDAsyncUdpSocket.h")
        sh("/bin/rm -rf ../Pods/CocoaAsyncSocket/Source/GCD/GCDAsyncUdpSocket.m")
    end

    private_lane :test_UITests do
        run_tests(
            scheme: "UITests",
            devices:["iPhone 8"]
        )
    end

    private_lane :test_MonkeyTest do
        run_tests(
            scheme: "MonkeyTest",
            devices:["iPhone 8"]
        )
    end

end

Jenkinsfile

$ cat Jenkinsfile
#!/usr/bin/env groovy

pipeline {
    agent {
        label 'ios'
    }
    environment {
        BUILD_FLAVOR = build_flavor(env.BRANCH_NAME)
        TEST_FLAVOR  = test_flavor(env.GIT_COMMIT)
    }
    stages {
        stage('Update fastlane') {
            steps {
                sh "bundle update fastlane"
            }
        }
        stage('Build alpha ipa for debug') {
            when {
                expression {
                    "${BUILD_FLAVOR}" == "debug"
                }
            }
            steps {
                sh "bundle exec fastlane ios alpha testmodel:${TEST_FLAVOR}"
            }
        }
        stage('Build release to testFlight') {
            when {
                expression {
                    "${BUILD_FLAVOR}" == "release"
                }
            }
            steps {
                sh "bundle exec fastlane ios beta"
            }
        }
        stage('Archive') {
            when {
                expression {
                    "${BUILD_FLAVOR}" == "release"
                }
            }
            steps {
                archiveArtifacts artifacts: "build/doufu_tf_${BUILD_FLAVOR}.ipa", fingerprint: true
            }
        }
    }
    post {
        failure {
            notifyFailed()
        }
        success {
            notifySuccessed("${BUILD_FLAVOR}")
        }
    }
}

#版本发布逻辑,如果迁出r开头接数字的分支,则当成release构建
def build_flavor(branch_name) {
    if (branch_name ==~ /r[.0-9]+/ || branch_name == 'master') {
        return 'release'
    }
    return 'debug'
}

#monkey测试逻辑
def test_flavor(git_commit) {
    commit_msg = sh (script: "git log -n 1 --pretty=format:%s ${git_commit}", returnStdout: true).trim()
    return commit_msg =~ /\[monkey\]/ ? 'monkey' : 'ui'
}


def notifySuccessed(flavor) {
    location = (flavor == "debug") ? "<a href='http://jenkins.ai-t.com.cn:8081/ios/doufu_tf_${flavor}.ipa'> 点击下载 </a>" : "TestFlight 下载"
    emailext (
        to: 'all_company@ai-t.com.cn',
        subject: "「成功」ait_ios ${env.BRANCH_NAME} 构建成功: ${currentBuild.fullDisplayName}, Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'",
        body: """<p>恭喜,最新代码构建成功!Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]':</p>
            <p>请大家前往 '${location}' 并进行测试。</p>
            <br/>
            <p>构建信息: <a href='${env.BUILD_URL}'>${env.JOB_NAME} [${env.BUILD_NUMBER}]</a></p>""",
      recipientProviders: [[$class: 'DevelopersRecipientProvider']]
    )
}

def notifyFailed() {
    emailext (
        to: 'dev_group@ai-t.com.cn',
        subject: "「失败」ait_ios ${env.BRANCH_NAME} 构建失败: ${currentBuild.fullDisplayName}, Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'",
        body: """<p>抱歉,最新代码构建失败!Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]':</p>
        <p>构建信息: <a href='${env.BUILD_URL}'>${env.JOB_NAME} [${env.BUILD_NUMBER}]</a></p>""",
        recipientProviders: [[$class: 'DevelopersRecipientProvider']]
    )
}

特别提醒:pipeline-->agent-->label中的名字“ios”在我们的jenkins里就是本地机器argo

可以尝试的一些fastlane插件

https://github.com/hjanuschka/fastlane-plugin-update_project_codesigning

遇到的问题及解决方案

签名问题

问题1:[BCEROR]Code Signing Error

错误提示[BCEROR]Code Signing Error: "SpokenEvaluation" requires a provisioning profile with the Push Notifications and In-App Purchase features. Select a provisioning profile for the "Release" build configuration in the project editor.
原因:这个问题出现的原因是fastlane在运行的时候没有匹配到合适的provisioning profile。
解决方案1:在Xcode中添加apple账号,使用XCode的automatically manage siging。 自动签名功能会同步相关的crenditial信息及必要内容,确保fastlane使用xcodebuild的时候能够有效。
解决方案2:在Xcode中不使用自动签名,但是明确指定debug和release模式用到的provisioning profile信息。缺点是不方便自动化。

问题2:Invalid Provisioning Profile

错误提示:[Transporter Error Output]: ERROR ITMS-90161: "Invalid Provisioning Profile. The provisioning profile included in the bundle com.xxxx.xxx [Payload/SpokenEvaluation.app] is invalid. [Missing code-signing certificate]. A Distribution Provisioning profile should be used when submitting apps to the App Store. For more information, visit the iOS Developer Portal."
原因:match的type跟gym的export_method、configuration不匹配。
解决方案
match:appstore, development, adhoc, enterprise四种,
export_method:app-store, development, ad-hoc, package, enterprise, developer-id几种,是xcodebuild的参数。
如果match的type选择appstore,那gym的export_method不能选development,ad-hoc之类,gym的configuration也只能选Release,不能选Debug。 app-store模式也是上传到testflight的要求。
TestFlight apps submitted to iTunesConnect need to be signed with an App Store Distribution Profile. TestFlight no longer accepts apps submitted with an Ad Hoc profile.

问题3:No matching provisioning profiles found

错误提示:No matching provisioning profiles found for "your.app" None of the valid provisioning profiles allowed the specified entitlements: beta-reports-active.
原因: Since App Store provisioning profiles are the only profiles containing the beta-reports-active entitlement, the error indicates that an App Store provisioning profile matching your app's bundle identifier couldn't be found in Xcode's local profile library.
解决方案:beta-reports-active是2014年9月新加的,只能用于appstore类型的profile。需要调整profile。First, be sure that you are using an App Store Distribution Provisioning Profile. This is likely a different provisioning profile from the Ad Hoc Distribution Provisioning Profile you were using to sign pre-Apple TestFlight builds.

问题4: invalid byte sequence in UTF-8

在使用update_info_plist、set_info_plist_value等方法的过程中出现这个问题。原因在于,这几个方法只能操作当前项目下的文本格式的plist,对于很多resource bundle的plist,往往都是binary格式的,这几个方法不适用。合理的解决方案是使用cfpropertylist来操作这些plist。

问题5:CocoaPods was not able to update the master repo

原因:可能是网络问题导致
解决方案:如果网络环境好转一下,重新运行依然有问题,可以重装cocoapods。

$ pod repo remove master
$ pod install

问题6:register_device阶段FASTLANE_PASSWORD environment variable

错误提示:Missing password for user xxxx@126.com, and running in non-interactive shell. You can also pass the password using the FASTLANE_PASSWORD environment variable
原因:使用fastlane在register_device的过程中需要登录dev网站,需要有访问apple id登录的能力。
方案1:把keychain中的对应密码权限开放到所有程序
方案2:设置FASTLANE_PASSWORD在环境变量中。

export FASTLNAE_PASSWORD="xxxx"

问题7:match安装certificates和profiles阶段,MATCH_PASSWORD environment variable

错误提示:Neither the MATCH_PASSWORD environment variable nor the local keychain contained a password. Bailing out instead of asking for a password, since this is non-interactive mode. Couldn't decrypt the repo, please make sure you enter the right password!
原因:match需要有能力访问keychain中的密码来解码私有git。
方案:设置MATCH_PASSWORD在环境变量中

export MATCH_PASSWORD="xxxx"

问题8:jenkins构建找不到bundle

错误描述:jenkins的node节点使用ssh-slaves插件进行连接的时候,环境变量没有设置,导致找不到bundle之类的命令
方案:在jenkins插件ssh-slaves的连接处,设置Prefix Start Slave Command 为 "source ~/.bashrc && " (intended whitespace at the end)。同时,可以在 ~/.bashrc中把FASTLANE_PASSWORD和MATCH_PASSWORD设置进去。

问题9:ios自定义脚本运行出错

错误描述:Running script '[CP] Embed Pods Frameworks'阶段,/usr/bin/codesign 出现unknown error -1=ffffffffffffffff
原因:codesign需要有权限使用developer的私钥对打包内容进行签名,在交互情况下会弹出提示框,让用户输入密码授权;在CI环境下由于没有UI,所以报错。
方案1:把ios开发相关的certificates 从 login keychain 拖拽到 System keychain (没有试过)
方案2:在环境中执行以下命令,对codesign进行授权。以下命令每次构建都需要执行,可以考虑结合上一个问题的方案,跟环境变量一起放在~/.bashrc中

$ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <keychainPass> ~/Library/Keychains/login.keychain-db

其中的如果没有专门设置过的话应该就是用户的macos登录密码

参考文档