LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

LIVESENSE ENGINEERING BLOG

MENU

Bitrise + fastlane で自動化していることを紹介します

f:id:roana0229:20190529192957p:plain

デイキャンプとカメラにハマっているネイティブアプリグループの堤下(@roana0229)です。

みなさんはCI導入していますか?
今まで弊社のiOS,AndroidアプリではCIが導入できていない現状がありましたが、2月末にリリースしたマッハバイトiOS版の開発を機に導入して、自動化していることを紹介します。

CIサービスは色々ありますが最近はよくBitriseを導入しているのを見かけます。ビルド環境への対応の早さやGUIでの操作しやすさから弊社もBitriseを導入しました。

静的なチェックの自動化

UnitTest以外では、静的なチェックでdangerを利用しています。dangerではgitの差分が扱いやすいようになっています。例えば、Swaggerを利用していてるのでAPI定義のyamlとswagger-codegenによって生成されるコードの差分をチェックしています。

swagger_file = "swagger/api_doc.yaml"
swagger_auto_generated_files = ".+/Data/API/.+"
has_swagger_changes = !git.modified_files.grep(/#{swagger_file}/).empty? || !git.deleted_files.grep(/#{swagger_file}/).empty?
has_swagger_auto_generated_changes = !git.modified_files.grep(/#{swagger_auto_generated_files}/).empty? || !git.deleted_files.grep(/#{swagger_auto_generated_files}/).empty?
if has_swagger_changes
  if has_swagger_auto_generated_changes
    message("`api_doc.yaml`,`自動生成されたファイル(Data/API/**)`の変更が含まれています。")
  else
    warn("`api_doc.yaml`が変更されていますが、`自動生成されたファイル(Data/API/**)`に変更がありません。`Data/API/**`ファイルを変更する必要がないか確認してください。")
  end
end

PRには、このようにdangerによるコメントが付けられます。

f:id:roana0229:20190528203131p:plain
API定義と自動生成されるファイルに変更がある時のコメント
f:id:roana0229:20190528203249p:plain
API定義のみ変更がある時のコメント

Swaggerの導入は今回が初めてだったこともあり、自動生成されるだけのファイルやAPI定義だけ更新されていたり、意図しないAPI実装の変更をしてしまわないように気付くことができるようになりました。

他にも、開発ブランチの最新の状態なのにXcodeで開くと警告が出ていたり、実ディレクトリとXcode上のディレクトリがズレている経験ってありませんか?
Dangerfileはrubyで記述されているため任意のコードを実行しやすく、realm/SwiftLintの警告やvenmo/synxが適応されているかなど、他のツールを用いたチェックを行っています。

def check_synx
  `bundle exec synx app.xcodeproj >/dev/null`
  synx_diff = `git diff app.xcodeproj/project.pbxproj`
  fail("`synx`を実行すると差分が発生しました。`bundle exec synx app.xcodeproj`し、`app.xcodeproj/project.pbxproj`の差分をコミットする必要があります。") unless synx_diff.empty?
end

swiftlint.config_file = '.swiftlint.yml'
swiftlint.binary_path = 'Pods/SwiftLint/swiftlint'
swiftlint.lint_files inline_mode: true, fail_on_error: true, additional_swiftlint_args: '--no-force-exclude'
check_synx

静的なチェックであるためCI実行してからPRに適応されるまでかかる時間も短く、GithubのBranch protection rulesを設定しておくことで、簡単にブランチの最低限の品質を保つことができます。

細かいところが気になって、本質的なコードレビューから意識がズレてしまうことが過去にあったのですが、これらによりレビュー時に意識しなくてはいけないことを減らしコードに集中したレビューができるようになりました。

アプリ配布の自動化

アプリをリリースする時、一定の決まった作業がありますよね。これらはfastlaneを利用して、リリースのために必要なことは下記の3つで完了するようにしています。

  1. fastlaneでリリース準備用のコマンド実行する
  2. リリースPRのレビューを経てmasterにマージする
  3. (申請後に)Firebase CrashlyticsへdSYMをアップロードする

fastlaneでリリース準備用のコマンド実行する

リリース準備の処理はこのようになっています。

lane :release_update_version do |options|
  update_commit_to_latest(branch: options[:branch] || "develop") unless is_ci?

  update_version

  release_summary = get_done_cards_summary(github_token: ENV["Githubのトークン"])

  app_version = get_version_number(xcodeproj: "app.xcodeproj", target: "app")
  build_version = get_build_number

  git_add
  git_commit(path: "*", message: "v#{app_version} (build version: #{build_version})")
  push_to_git_remote

  pr_url = create_pull_request(
    repo: "リポジトリ",
    title: "#{app_version}(#{build_version})",
    base: "master",
    body: release_summary
  )

  sh("open #{pr_url}") unless is_ci?
end

今後CIでやることを見据えていますが、現在はローカルで実行しています。
そのため最初に実行される処理であるprivate_lane :update_commit_to_latestにより、期待しているブランチで実行されているか、ブランチを最新にして差分が発生していないかをチェックしています。

private_lane :update_commit_to_latest do |options|
  UI.user_error!("branch:ブランチ名 が必要です") unless options[:branch]

  base_branch = options[:branch]
  ensure_git_branch(branch: base_branch) rescue UI.user_error!("#{base_branch}ブランチで実行してください")
  ensure_git_status_clean rescue UI.user_error!("差分がない状態にしてください")
  sh("git pull origin #{base_branch}")
  ensure_git_status_clean rescue UI.user_error!("#{base_branch}を最新にした状態で差分が発生しました、差分がない状態にしてください")
end

次に実行されるバージョンのアップデートはprivate_lane :update_versionのようにしておけば、ビルドバージョンの更新 or アプリバージョンを更新した時にビルドバージョンを1にすることができます。

private_lane :update_version do |options|
  bump_type = options[:bump_type] || UI.select("バージョンをアップデートしますか?: ", ["only build_version", "major", "minor", "patch"])
  if bump_type == "only build_version" then
    increment_build_number(
      xcodeproj: "app.xcodeproj"
    )
  else
    increment_build_number(
      build_number: 1,
      xcodeproj: "app.xcodeproj"
    )
    increment_version_number(
      bump_type: bump_type
    )
  end
end

その後にget_done_cards_summaryというRubyスクリプトにより、完了したタスクを整形しdevelop to masterPRが作成されます。
開発タスクはGithub Projectsで管理していてるため、APIを利用して完了したタスクを吸い上げ、その内容を整形した状態をPRに記載した状態で下記のようなPRが生成されるようになっています。

f:id:roana0229:20190528203625p:plain
リリース用のPRイメージ

整形しやすいようにPRには機能追加 or 修正が判別できるラベルを付けて判別しています。

f:id:roana0229:20190528203644p:plain
PRに指定する判別用ラベル

これにより、今回のリリースに含まれる内容を手作業でまとめ直す必要もなく、PRを共有するだけで良いため、リリース前にエンジニア以外が実機でテストするタイミングにも役立てています。

PRが作成されると、CIによりdeploygatestaging環境のアプリを配布され、slackに通知されます。このタイミングで修正が必要になった場合、通常通りPRを作り、developにマージすることでリリース用PRでもCIが再度走り、アプリが配布されます。

developへのマージ前でも任意のタイミングでBitriseをGUI上から操作して、ブランチを指定しdevelopment環境のアプリを配布しています。

リリースPRのレビューを経てmasterにマージする

リリースビルドを手元で行うと時間がかかり、ターミナルをまだ終わってないかとチラ見して集中力が削がれてしまっていました。そのため、今はマージするだけでCIによりリリース作業が行われるようにしています。

lane :release_to_store do |options|
  slack(
    slack_url: ENV["Slackの通知用URL"],
    pretext: "ストアへのリリース処理を開始します(is_ci?: #{is_ci?})",
    message: "ビルド時の情報 Git Commit Hash が正しいか確認してください",
    channel: "#チャンネル"
  )

  app_version = get_version_number(xcodeproj: "app.xcodeproj", target: "app")
  build_version = get_build_number
  release_summary = get_done_cards_summary(github_token: ENV["Githubのトークン"])

  build_ios_app(
    workspace: "app.xcworkspace",
    configuration: "Release",
    scheme: "app",
    clean: true,
    export_method: "app-store",
    output_directory: "./fastlane/generated",
    output_name: "app.ipa"
  )
  upload_to_app_store(
    force: true,
    team_id: "チームID",
    app_identifier: "アプリID",
    ipa: "./fastlane/generated/app.ipa",
    skip_screenshots: true,
    skip_metadata: true,
    skip_app_version_update: true,
    run_precheck_before_submit: false
  )

  set_github_release(
    repository_name: "リポジトリ",
    api_token: ENV["Githubのトークン"],
    name: app_version,
    tag_name: "#{app_version}(#{build_version})",
    description: release_summary,
    commitish: "master",
    is_prerelease: true
  )

  slack(
    slack_url: ENV["Slackの通知用URL"],
    pretext: "Release版のストアへのアップロードが完了しました!",
    message: "GitHub Releases: https://github.com/リポジトリ/releases",
    channel: "#チャンネル"
  )
end

※環境変数FASTLANE_USER,FASTLANE_PASSWORDupload_to_app_store時に利用するアカウントを指定することができます。

リリース作業が開始されたこと・終了したことをSlackに流しておくことで、他の人もリリース作業のステータスがどうなっているのかを知ることできます。

また、リリースPR作成時にも使用したスクリプトを使い、Github Releasesにリリースの履歴を残しています。リリース履歴にPRのURLも載っている状態になるので、過去の類似した変更やどういう意図で変更が入ったのかが追いやすくなりました。

リリース作業が完了した後にWorkflowのScriptステップでdevelopをmasterに追従するようにしています。忘れそうになることが多いかつ必ずしないといけないことなので、1ステップ追加するだけでサクッとできてとても助かっています。

Firebase CrashlyticsへdSYMをアップロードする

クラッシュ解析にはFirebase Crashlyticsを利用していて、dSYMをアップロードする必要があり、これにもコマンドを用意しています。

lane :upload_dsym_to_crashlytics do |options|
  app_version = get_version_number(xcodeproj: "app.xcodeproj", target: "app")
  build_version = get_build_number
  download_dsyms(
    team_id: "チームID",
    app_identifier: "app-identifier",
    output_directory: "./fastlane/generated/",
    version: app_version,
    build_number: build_version
  )

  upload_symbols_to_crashlytics(
    dsym_path: "./fastlane/generated/app-identifier-#{app_version}-#{build_version}.dSYM.zip",
    gsp_path: "./GoogleService-Info.plist"
  )
end

初めはdSYMのダウンロードを手動でやっていたのですが、同じことを毎回繰り替えすことになってしまうので、リリースするアプリのバージョンを元にApp Store ConnectからdSYMのダウンロードとFirebase Crashlyticsへアップロードまで行っています。

本来はこれも任意のタイミングではなく、自動で行いたいのですがupload_to_app_storeのあとApp Store Connect側での処理の完了を知ることができず、ストアの申請をしたあとに実行するようにしています。

fastlaneで自動化するために気をつけていること

最初はローカルで実行していたこともあり、CI上での動作の殆どはローカルでも同じことが試せるようにfastlaneでロジックをまとめています。

一つ気を付けた方が良いなと思ったのは全てをFastfileに書こうとしないということです。最初はごりごりFastfileに書いていったのですが、いわゆる神クラスのように何でも行われる便利ツールに近づいていってしまいました。例えば、ライブラリの更新処理などbash記述のみで完結する場合はupdate_library.shという感じにするだけで十分でした。

任意のbashスクリプトを一覧から呼び出せるfastlaneのコマンドを用意したりするのは便利かもしれませんが、各処理の詳細をfastlaneが知る必要がないのであればその方がFastfileの肥大化を防ぐことができます。継続的に管理していくファイルなので、可読性を保てるように意識した結果この考えになりました。

Bitriseで設定している全体像

紹介してきたもので実際に構成されているWorkflow,Triggerはこのようになっています。

Workflow

Workflow Description
primary 静的なチェック、UnitTestの実行
primary-with-deploy-beta 静的なチェック、UnitTestの実行
deploy-betaの実行
deploy-debug development環境のアプリを配布
deploy-beta staging環境のアプリを配布
release AppStoreConnectへアップロード
merge-master-into-developの実行
merge-master-into-develop developmasterに追従する

Trigger

Pull Request
f:id:roana0229:20190528205748p:plain
f:id:roana0229:20190528205816p:plain
f:id:roana0229:20190528205830p:plain

Push
f:id:roana0229:20190528205839p:plain f:id:roana0229:20190528205954p:plain

BitriseはWorkflowのあとに特定のWorkflowを実行できる仕組みがあるため、各Workflowは細かく別けて必要な時につなげています。Triggerに指定されていないdeploy-debug, deploy-betaは任意のタイミングで手動実行しています。

また、developへのPush時にはPR時と同じprimaryを実行していますが、これはBitriseのBuild CachesがPRのTriggerではキャッシュ更新ができないため、Push時にも行っています。

おわりに

以上、Bitrise + fastlaneで自動化していることの紹介でした。

今回初めてのCI導入でしたが思い切って入れてみて、体験の良さを感じています。過去の開発では、UnitTestの準備ができていないしCIを導入しても効果なさそうだな…ということがあったのですが、今ではUnitTestの有無に関係なくCIを導入すべきだなと思っています。

また、今回紹介したようなCI環境を用意したことで、開発以外のビルドをローカルで行うことがなくなり待ち状態でPCリソースが奪われることが減ったため、結果的に自由に使うことが出来る時間が多くなりました。

他にも、ライブラリの自動更新検知やその時のキャッシュをローカルでも利用できるようにしたりなど、まだやれていないことが今後も新しく出てきたりすると思うので、自動化出来ることを増やして行こうと思います。