Integrate pipeline for iOS projects using GitLab CI

相信每个人都有这样一个愿景,就是希望自己维护的每一个组件都能跟知名的开源项目一样优秀,有高的代码质量,完整的测试和文档。为了达到这一目的,就需要有 CI 作为项目开发过程中的一个环节介入。在每次 push 或者提交 Merge Request 的时候,CI 可以代替人来做一些事情,编译项目,跑跑测试,做一些静态检测,帮助提交者和 reviewer 及时发现一些简单的问题,提高工作效率。

CI 都能做些什么?

对于 iOS 组件来说,以下几件事在 CI 阶段做是比较有帮助的,
  • build 项目并运行所有测试,给出 Code Coverage
  • 使用 lint 工具对代码进行静态分析
  • 使用 danger 找出 MR 以及 MR 改动中可能存在的问题
以上的几步都会通过 danger 在 MR 下面给出评论,效果如图,
notion image

GitLab runner

选择 GitLab runner 作为 CI 平台是很自然事情,它 UI 好看(是的 Jenkins 太复古了),集成简单,而且还可以作为 MR 的合并流程的其中一环,比如 pipeline 没过,就无法合并,
notion image

.gitlab-ci.yml

想要 runner 执行上边提到的几个步骤,需要在仓库的根目录添加 .gitlab-ci.yml ,这个文件告诉了 runner,每次 push 或者 MR 的提交后,它应该做什么事情。关于这个文件怎么写,官网写得比较清楚,这里就不再赘述了,就谈谈我觉得需要注意的一些问题吧,
  • 一个 stage 过后产出的文件是不能被下一个 stage 获取到的,如果想这样,需要在 stage 中添加 artifacts 字段,指定文件的路径
xxx_stage:
  stage: xxx
  artifacts:
    paths:
    - xxx.abc
  • 对于多个仓库想要使用同一个 runner 的情况,需要在每个 stage 中添加 tags 字段,标示 runner 的名字,而且需要在仓库的配置里面设置一下
  • 如果遇到字符集的问题,可以在 before_script 中添加下面行
before_script:
  - export LANG=en_US.UTF-8
  - export LANGUAGE=en_US:en
  - export LC_ALL=en_US.UTF-8

lint & Code Coverage

因为项目是 Objective-C 和 Swift 混编的,所以两种语言的 lint 都要有。因为还处于搭建的初期,而且 lint 的规则还处于变化的过程中,所以并没有把 lint 的结果作为一种很强的约束(比如 lint 不过,pipeline 就会 fail),目前只会把它的结果作为一种提示,评论在 MR 的下面,后面在这套流程走入正轨,在 lint 工具支持的情况下(swiftlint),会补上这一功能

OCLint

Objective-C 相关的 lint 工具找了一圈,没有又简单又美观又易用的,只有 OCLint 能将就用一下。Lint 结果需要在 build 工程之后才能给出,所以稍微麻烦一些,前期配置花了一些时间,
# .gitlab-ci.yml

build_project:
  script:
    - cd Example
    - pod install
    - xcodebuild clean -workspace ${POD_NAME}.xcworkspace -scheme ${POD_NAME}-Example | xcpretty
    # 指定 derived data 目录是不想让每次编译都被缓存,OCLint 对没有更新的 build 不会产生结果
    - xcodebuild test -workspace ${POD_NAME}.xcworkspace -scheme ${POD_NAME}-Example -destination 'platform=iOS Simulator,name=iPhone X,OS=11.2' -derivedDataPath build_outputs | xcpretty -r json-compilation-database -o compile_commands.json
    # 在远端脚本更新以后,不希望每一个仓库都作出同样的更改,所以把这个脚本放到另外一个仓库
    - git archive --remote=xxx.git HEAD ci-lint.rb | tar xvf -
    - ruby ci-lint.rb
# ci-lint.rb

def get_oclint_comment
  # 下载 oclint 的配置文件
  download_file(".oclint")
  # 让 oc-lint parse 刚才 build 生成的 `compile_commands.json` 文件
  oclint_output = `oclint-json-compilation-database -v -e Pods`
  puts "----- origin OCLint output -----"
  puts oclint_output
  # 对 oclint 结果做一些处理,比如找到 `TotalFiles=0` 就认为 lint success
  oclint_comment = parse_oclint_output(oclint_output)
  puts "----- parsed OCLint output -----"
  puts oclint_comment
  oclint_comment
end

swiftlint

比 OCLint 好很多,简单美观且易用,社区活跃,不需要编译工程;唯一不能处理的就是一些编译过后才能发现的问题,比如嵌套过深等等(其实这些检测结果多数情况没卵用)。.swiftlint 同样放在其它仓库,方便规则变更,记得把 reporter 改成 emoji ,结果会更好看。
# ci-lint.rb
# 获取 swiftlint 的结果

def get_swiftlint_result
  swiftlint_comment_content = `swiftlint`
  puts "----- origin swift lint content -----"
  if not swiftlint_comment_content.include? "Line"
    # 不包含 `Line`,我们认为 lint 通过
    swiftlint_comment_content = "\\n## swiftlint report\\n✅ success\\n"
  else
    # 否则不通过
    swiftlint_comment_content = "\\n## swiftlint report\\n```\\n" + swiftlint_comment_content + "\\n```"
  end
  swiftlint_comment_content
end

def get_swiftlint_comment
  # 需要在根目录执行
  Dir.chdir("../")
  # 下载 swiftlint 配置文件
  download_file(".swiftlint.yml")
  swiftlint_comment = get_swiftlint_result
  puts "----- parsed swiftlint content -----"
  puts swiftlint_comment
  swiftlint_comment
end

Code Coverage

使用的工具是 slather,配合 GitLab 原生的 Code Coverage 显示,用正则过滤一下 output 就能获取结果,
# .slather.yml

# 在 CI 阶段,用来跑 Code Coverage 使用的配置文件
# ${PROJECT_NAME} 需要被替换掉

configuration: Debug
input_format: profdata
xcodeproj: ${PROJECT_NAME}.xcodeproj
workspace: ${PROJECT_NAME}.xcworkspace
scheme: ${PROJECT_NAME}-Example
# 这个目录是上面 derived data 指定的目录
build_directory: build_outputs
binary_basename: ${PROJECT_NAME}
ignore:
  - "Pods/*"
# ci-lint.rb

# 获取每个文件的 coverage 比例结果
def get_coverage_comment
  slather_yml_file_name = ".slather.yml"
  # 下载 slather 的配置文件
  download_file(slather_yml_file_name)
  # 把 `${PROJECT_NAME}` 替换成 $CI_PROJECT_DIR
  modify_slatheryml(slather_yml_file_name)
  "\\n## Code Coverage Report\\n```\\n" + `slather coverage` + "\\n```"
end
# 配置 .gitlab-ci.yml,即可在 MR 中显示 Code Coverage
build_project:
  coverage: '/\\d+(?:\\.\\d*)?\\%$/'

danger

danger 能够获取信息都是与 MR 有关的,比如修改了哪些文件,改动有多少行,有没有填写描述等等,具体的内容可以看它的文档。在 GitLab 上集成 danger 有些问题,如果是是 fork 仓库向主库提交 MR,danger 就会获取不到正确的 project_idmr_iid,它就不能找到对应的 MR 进行评论,这就需要我们帮它找到对应的值并告知,
# .gitlab-ci.yml

danger:
  stage: danger
  script:
    - git archive --remote=xxx.git HEAD ci-danger.sh | tar xvf -
    - sh ci-danger.sh
# ci-danger.sh

# 下载 Dangerfile
git archive --remote=xxxx.git HEAD Dangerfile | tar xvf -
# 下载获取 mr_iid 的脚本
git archive --remote=xxx.git HEAD ci-mr_iid.rb | tar xvf -

# 获取 project_id 和 mr_iid
output=$(ruby "ci-mr_iid.rb")
project_id=$(cut -d',' -f1 <<< $output)
mr_iid=$(cut -d',' -f2 <<< $output)

# 运行 danger 命令,把获取到的字段作为 ENV 传给 danger
DANGER_GITLAB_HOST=git.xxx.com \\
  DANGER_GITLAB_API_BASE_URL=https://git.xxx.com/api/v4 \\
  DANGER_GITLAB_API_TOKEN=xxx \\
  CI_PROJECT_ID=$project_id \\
  CI_MERGE_REQUEST_ID=$mr_iid \\
  danger
# ci-mr_iid.rb
# 作用是找到当前仓库的 project_id 和 mr_iid

require 'gitlab'

project_name = ENV['CI_PROJECT_NAME']
# GitLab private token
private_token = "xxx"
# 所有仓库所在的 group id,需要用 API 获取一下
platform_groud_id = "123"
commit_sha = ENV['CI_COMMIT_SHA']

# 在这个 group 中寻找名字一样的 project,获取它的 project_id
g = Gitlab.client(endpoint: '<https://git.xxx.com/api/v4>', private_token: private_token)
platform_group = g.group(platform_groud_id)

project_id = platform_group.projects.find{ |p| p["name"] == project_name }["id"].to_s
# 这一次 commit 的 SHA,用来获取 mr_iid
# 获取 mr_iid
mr_cmd = "curl -s \\"<https://git.xxx.com/api/v4/projects/#{project_id}/merge_requests?private_token=#{private_token}&state=opened\\>" | jq -r \\".[]|select(.sha == \\\\\\"#{commit_sha}\\\\\\")|.iid\\""
mr_iid = `#{mr_cmd}`

ENV['CI_PROJECT_ID'] = project_id
ENV['CI_MERGE_REQUEST_ID'] = mr_iid

puts project_id + "," + mr_iid
以上的各个步骤,除了 .gitlab-ci.yml 以外的脚本及配置文件,都是从其它仓库下载下来的,这样做的好处有很多,让配置的规则有动态更新的能力,让每个仓库保持干净,没有代码没有多余的无关文件可以隐藏细节(这也算是一种程度的封装吧😆)等等。如果有更好的方式,以及上面的代码有什么问题,欢迎提出意见

pod template

如果要让每一个独立组件都适应类似的 .gitlab-ci.yml ,就需要做一番大改动了。每个组件都有自己的 git 仓库,不管对于已经存在的组件,还是未来将要出现的组件,都需要提供一套快速创建出一个可以直接支持当前 CI workflow 的工程模版。
可能很多人都不知道,CocoaPods 自带一条命令,pod lib create,它可以通过一个模板的 git 链接创建一个组件。我们可以通过 --template-url 来指向自己需要的模板,从而达到快速创建工程的目的。
notion image

pod template 文件组织

假如有这样一个 pod 叫 BestFramework,我是这样组织它的结构的,
~/D/BestFramework tree . -L 2 -a
.
├── .gitignore
├── .gitlab-ci.yml
├── BestFramework
│   ├── Classes
│   └── Resources
├── BestFramework.podspec
├── Example
│   ├── BestFramework
│   ├── BestFramework.xcodeproj
│   ├── BestFramework.xcworkspace
│   ├── Podfile
│   ├── Podfile.lock
│   ├── Pods
│   └── Tests
├── README.md
└── _Pods.xcodeproj -> Example/Pods/Pods.xcodeproj
把 BestFramework 当作一个 Development Pods 作为 Example 的依赖,测试放到 Example/Tests 中,gitlab-ci.yml 中的内容是根据创建时候给出的名字动态创建的。其实文件夹结构这种东西怎么组织都可以,只要外面能正确引用就可以了
最后以这样一幅图作为总结,
notion image
所以我们主要覆盖的是创建项目以及提交 MR 两个阶段,提供一些效率工具及使用 CI 帮助我们更好更快地书写/review代码

© Xinyu 2014 - 2024