🛠️

fastlaneを用いたCircleCIによるReactNativeアプリのビルドを自動化した話

2021/12/06に公開

こんにちは、harashun11です。
この記事は、airCloset Advent Calendar 2021 の6日目に寄稿させていただいてます。
フリーランスエンジニアとして活動している中での、株式会社エアークローゼット様(以下、エアクロ)の開発の一部を紹介させていただきます。

概要

エアクロでは、ベトナムオフショアを活用させていただいたり、テスターを外部にアウトソーシングしているので人の出入りが多く、PLを含めた非エンジニアの皆さんがプロジェクト単位でアサインされるため、実機で動作確認するまでの連携が結構面倒になってました。
なので、
誰がコミットしても、誰でもすぐ実機で確認できる
を要件とし、
fastlaneを用いたCircleCIによるReactNativeアプリビルド自動化
の対応をしたので、その内容を紹介します。

開発環境

ReactNativeでios/android向けのアプリを提供していて、CI環境としては、CircleCIを使ってます。
また、動作確認方法としては、ステージング環境を向き先としたアプリをDeployGateに展開してプロジェクトに関わる人に共有してます。

対応方針

fastlaneの利用

fastlaneを使います。
Ruby製のツールで、ドキュメントがしっかり整っているのと、iOSアプリビルド時のかゆいところにも手が届くので、fastlaneを採用しました。そのかゆいところについては、以下がわかりやすかったです。
https://engineering.visional.inc/blog/_161/ios-manage-certificates/
特に、エアクロでは前述通りオフショアを活用させていただいているので、エンジニアの出入りが多くあると想定され、iOS開発用証明書の発行からセットアップは結構面倒になるなと考えられました。
なので、Github Teamsに依存したread/write権限付与と、Github Repositoryに暗号化したProvisioning Profileを置くことで必要な証明書は各自取得できるようになることはかなり作業効率が上がるので、メリットと感じました。

CircleCIでmacOS VMが必要

iosアプリビルドをする際にxcodeが必要なので、maxOS VMを利用します。
その際、CircleCIのプランをupgradeする必要があります。

エアクロのワークフローに適応する

Staging向けアプリをDeployGateにデプロイして、社内メンバーなどが動作確認してます。
そのため、DeployGateへのアップロードまでをCircleCI上で実現します。
また、Slack上でコミュニケーションを取っているので、アップロード成功やエラーの通知はSlackへ通知します。

ReactNativeにfastlane導入

Gemfile

source "https://rubygems.org"

# setup_circle_ci action was released in 2.107.0
gem 'fastlane', '>=  2.191'

できる限り共通環境とするためGemfileで導入してます。
ReactNativeアプリのroot dirに置きました。

fastlane

fastlaneでは、インタラクティブモードで指定しながら動かすことができますが、CircleCI上で動かしたいので、ある程度指定する必要があるのと、簡単に動くようにtaskをios/androidで別々に表現します。

android/fastlane

androidは簡単です。
ビルドには、fastlane上で、gradleが用意されているので、利用するだけです。

Fastfile全部見たい人はこちらをクリック
android/fastlane/Fastfile
fastlane_version "2.191.0"

default_platform :android

platform :android do
  before_all do
    # https://docs.fastlane.tools/best-practices/continuous-integration/circle-ci/
    setup_circle_ci
  end

  desc "Build a new version for release"
  lane :build_release do
     gradle(
      task: "assemble",
      build_type: "Release"
    )
  end

  desc "Build a new version to upload to deploygate"
  lane :build_for_deploygate do
    commit = last_git_commit
    build_release
    deploygate(
      api_token: ENV["DEPLOYGATE_API_TOKEN"],
      user: ENV["DEPLOYGATE_USER"],
      apk: "./app/build/outputs/apk/release/app-release.apk",
      message: "uploaded with fastlane, branch: #{git_branch}, last commit message: #{commit[:message]}, last commit hash: #{commit[:abbreviated_commit_hash]}"
    )
    slack(
      message: "assembleReleaseなandroidアプリビルドとdeploygateへのアップロードに成功しました",
      payload: {
        "App Name" => "{YOUR_APP_NAME}"",
	"Circle Build Url" => ENV["CIRCLE_BUILD_URL"]
      }
    )
  end

  desc "Build a new version to upload to beta"
  lane :build_for_beta do
    build_release
    # 省略
  end

  # error block is executed when a error occurs
  error do |lane, exception|
    puts exception
    slack(
      # message with short human friendly message
      message: exception.to_s,
      success: false,
      # Output containing extended log output
      payload: {
        "Output" => exception.to_s,
        "Circle Build Url" => ENV["CIRCLE_BUILD_URL"]
      }
    )
  end

end

ios/fastlane

iosは結構面倒です。
ビルド時に、Provisioning Profileが必要となってきます。

Fastfile全部見たい人はこちらをクリック
ios/fastlane/Fastfile
fastlane_version "2.191.0"

default_platform :ios

platform :ios do
  before_all do
    # https://docs.fastlane.tools/best-practices/continuous-integration/circle-ci/
    setup_circle_ci
  end

  desc "Build a new version for adhoc"
  lane :build_adhoc do |options|
    sync_code_signing(type: "adhoc", readonly: true)
    update_code_signing_settings(
      path: "{YOUR_APP_NAME}.xcodeproj",
      code_sign_identity: ENV["IOS_CODE_SIGN_ID"],
      profile_name: ENV["IOS_ADHOC_PROFILE_NAME"],
      use_automatic_signing: false,
    )
    build_ios_app(
      export_method: "ad-hoc",
      output_directory: "./fastlane/build/adhoc/",
      export_options: {
        provisioningProfiles: {
          "{YOUR_APP_BUNDLE_ID}" => ENV["IOS_ADHOC_PROFILE_NAME"]
        }
      }
    )
    update_code_signing_settings(
      path: "{YOUR_APP_NAME}.xcodeproj",
      code_sign_identity: ENV["IOS_CODE_SIGN_ID"],
      profile_name: ENV["IOS_ADHOC_PROFILE_NAME"],
      use_automatic_signing: true,
    )
  end

  desc "Build a new version to upload to deploygate"
  lane :build_for_deploygate do
    commit = last_git_commit
    build_adhoc
    deploygate(
      api_token: ENV["DEPLOYGATE_API_TOKEN"],
      user: ENV["DEPLOYGATE_USER"],
      ipa: "./fastlane/build/adhoc/aircloset.ipa",
      message: "uploaded with fastlane, branch: #{git_branch}, last commit message: #{commit[:message]}, last commit hash: #{commit[:abbreviated_commit_hash]}"
    )
    slack(
      message: "adhoc用のiosアプリビルドとdeploygateへのアップロードに成功しました",
      payload: {
        "App Name" => "{YOUR_APP_NAME}",
        "Circle Build Url" => ENV["CIRCLE_BUILD_URL"]
      }
    )
  end

  desc "Build a new version for appstore"
  lane :build_appstore do
        # 省略
  end

  desc "Build a new version to upload to testflight"
  lane :build_for_testflight do
    # 省略
  end

  # error block is executed when a error occurs
  error do |lane, exception|
    slack(
      # message with short human friendly message
      message: exception.to_s,
      success: false,
      # Output containing extended log output
      payload: {
        "Output" => exception.to_s,
        "Circle Build Url" => ENV["CIRCLE_BUILD_URL"]
      }
    )
  end

end
sync_code_signing(type: "adhoc", readonly: true)

Github Repository上に存在するProvisioning Profileにアクセスします。readonlyとすることで、write権限を必要とせずread権限を持つアカウントがProvisioning Profileを取得することができます。
なので、初めてローカルでiosアプリビルドするために、Provisioning Profileを必要とする際には、githubアカウントでread権限を持っていれば、

bundle exec fastlane match { development|adhoc|appstore } --readonly

CLI上で取得できます。便利。
ちなみに、matchsync_code_signingのエイリアスです。

update_code_signing_settings(
  path: "{YOUR_APP_NAME}.xcodeproj",
  code_sign_identity: ENV["IOS_CODE_SIGN_ID"],
  profile_name: ENV["IOS_ADHOC_PROFILE_NAME"],
  use_automatic_signing: false,
)

ビルド時にProvisioning Profileを指定するため、update_code_signing_settingsuse_automatic_signing: falseにしてからbuildする必要があります。(use_automatic_signing: trueに戻しているのは、ローカルでも動かせるようにしているためです。)
https://qiita.com/nokono/items/1d3d200eef7ea7d705e3
このあたりは参考になりました。


以上、fastlane側の設定でした。
ちなみに、ENV指定することで、CircleCI > Project Settings > Environment Variables に設定して呼び出せます。ローカルで使うときはdotenv&direnvでも入れると良いと思います。
Matchfileをいろいろ指定してたりしますが、そんなに重要じゃないので、割愛させていただきます。

CircleCI上でfastlane動かす

circleciの設定全部見た人はこちらをクリック
.circleci/config.yml
version: 2.1

orbs:
  node: circleci/node@4.7.0
  android: circleci/android@1.0.3

executors:
  ios-build-machine:
    macos:
      xcode: "12.5.1"

commands:
  setup_to_build:
    description: "アプリビルドに必要な共通セットアップ処理"
    steps:
      ## 省略 - node_modulesのinstallなどが必要です。##
      ## install gems ##
      - restore_cache:
          keys:
            - gem-v1-{{ arch }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}
      - run: bundle check || bundle install --path vendor/bundle
      - save_cache:
          key: gem-v1-{{ arch }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}
          paths:
            - vendor/bundle
  setup_to_build_ios:
    description: "iosビルドに必要な共通セットアップ処理"
    steps:
      - run:
          name: jsbundle init
          command: yarn ios-bundle # npx react-native bundle --entry-file index.js --platform ios --dev false --bundle-output ios/main.jsbundle --assets-dest ios
      ## install cocoapods ##
      - restore_cache:
          keys:
            - cocoapods-v1-{{ checksum "ios/Podfile.lock" }}
            - cocoapods-v1
      - run:
          name: pod install
          command: cd ios && bundle exec pod install
      - save_cache:
          key: cocoapods-v1-{{ checksum "ios/Podfile.lock" }}
          paths:
            - ios/Pods
  setup_to_build_android:
    description: "androidビルドに必要な共通セットアップ処理"
    steps:
      ## build ##
      - run:
          name: yarn android-bundle
          command: yarn android-bundle # npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output android /app/src/main/assets/index.android.bundle
          no_output_timeout: 30m
  setup_env_to_stg:
    description: "stgの環境変数をセットする"
    steps:
      - run:
          name: set env stg
          command: grep -v '^\s*#' env.staging | grep -v '^\s*$' | while read line; do echo "export ${line}" >> $BASH_ENV; done
      - run:
          name: reload BASH_ENV
          command: |
            source $BASH_ENV
            echo $AC_API_URL
  ## setup_env_to_prod: 省略 ##
 

jobs:
  ## npm_dependencies: 省略 ##

  ## test: 省略 ##
  
  build_ios_stg:
    executor: ios-build-machine
    working_directory: /Users/distiller/project
    steps:
      - checkout
      - setup_env_to_stg
      - setup_to_build
      - run:
          name: set env to staging
          command: yarn switch-config:stg
      - setup_to_build_ios
      ## build ##
      - run:
          name: fastlane ios for staging
          command: cd ios && bundle exec fastlane build_for_deploygate
      - run:
          name: zip
          command: cd ios/fastlane/build && zip -r ipa.zip adhoc
          when: always
      - store_artifacts:
          path: /Users/distiller/project/ios/fastlane/build
 
  build_android_stg:
    executor:
      name: android/android-machine
      resource-class: medium
    environment:
      JVM_OPTS: -Xmx3200m
      GRADLE_OPTS: '-Dorg.gradle.daemon=false -Dorg.gradle.jvmargs="-Xmx3200m -XX:+HeapDumpOnOutOfMemoryError"'
    working_directory: ~/project
    steps:
      - checkout
      - setup_env_to_stg
      - setup_to_build
      - run:
          name: set env to staging
          command: yarn switch-config:stg
      - setup_to_build_android
      - run:
          name: fastlane android for staging
          command: cd android && bundle exec fastlane build_for_deploygate
      - run:
          name: zip
          command: cd android/app/build/outputs && zip -r apk.zip apk
          when: always
      - store_artifacts:
          path: /home/circleci/project/android/app/build/outputs

  ## build_ios_prod: 省略 ##
  
  ## build_android_prod: 省略 ##
  
  workflows:
  version: 2
  build-test:
    jobs:
      - test:
          requires:
            - npm_dependencies
      - build_ios_stg:
          requires:
            - test
          filters:
            branches:
              only:
                - /feature.*/
                - master
      - build_ios_prod:
          filters:
            tags:
              only: /.*/
            branches:
              ignore: /.*/
      - build_android_stg:
          requires:
            - test
          filters:
            branches:
              only:
                - /feature.*/
                - master
      - build_android_prod:
          filters:
            tags:
              only: /.*/
            branches:
              ignore: /.*/

version: 2.1

orbs:
  node: circleci/node@4.7.0
  android: circleci/android@1.0.3

versionを2.1にしてorbs使います。
gradleもcircleci/android使えば、defaultでinstall済みっぽいです。

commands:
  setup_to_build_ios:
    description: "iosビルドに必要な共通セットアップ処理"
    steps:
      - run:
          name: jsbundle init
          command: yarn ios-bundle # npx react-native bundle --entry-file index.js --platform ios --dev false --bundle-output ios/main.jsbundle --assets-dest ios
      ## install cocoapods ##
      - restore_cache:
          keys:
            - cocoapods-v1-{{ checksum "ios/Podfile.lock" }}
            - cocoapods-v1
      - run:
          name: pod install
          command: cd ios && bundle exec pod install
      - save_cache:
          key: cocoapods-v1-{{ checksum "ios/Podfile.lock" }}
          paths:
            - ios/Pods

最終的にappstoreへのアップロードもするので、iosビルドの共通セットアップ処理として切り出してます。
ReactNativeでiosビルドするため、npx react-native bundleをする必要がありました。
android側も同様です。

job:
  build_ios_stg:
    executor: ios-build-machine
    working_directory: /Users/distiller/project
    steps:
      - checkout
      - setup_env_to_stg
      - setup_to_build
      - run:
          name: set env to staging
          command: yarn switch-config:stg
      - setup_to_build_ios
      ## build ##
      - run:
          name: fastlane ios for staging
          command: cd ios && bundle exec fastlane build_for_deploygate
      - run:
          name: zip
          command: cd ios/fastlane/build && zip -r ipa.zip adhoc
          when: always
      - store_artifacts:
          path: /Users/distiller/project/ios/fastlane/build

iosディレクトリ下で、bundle exec fastlane build_for_deploygateすることで、先に示したfastlane/Fastlaneを見に行って動いてくれます。
また、ipaファイルをARTIFACTSに吐き出すことで、DeployGateにアカウント招待せずとも、ベトナムオフショア側もDeployGateで共有しているアプリを簡単に手に入れることが可能となってます。(これが一番みんなに喜ばれてます。)

job:
  (省略)
  build_android_stg:
    executor:
      name: android/android-machine
      resource-class: medium
    environment:
      JVM_OPTS: -Xmx3200m
      GRADLE_OPTS: '-Dorg.gradle.daemon=false -Dorg.gradle.jvmargs="-Xmx3200m -XX:+HeapDumpOnOutOfMemoryError"'
    working_directory: ~/project
    steps:
      (省略)

android側で注意すべきなのは、JVM_OPTSGRADLE_OPTSの指定です。
メモリ確保しないと普通にこけます。ひとまず上記のように設定すればいけました。
https://circleci.com/blog/how-to-handle-java-oom-errors/

  workflows:
  version: 2
  build-test:
    jobs:
      - test:
          requires:
            - npm_dependencies
      - build_ios_stg:
          requires:
            - test
          filters:
            branches:
              only:
                - /feature.*/
                - master
      - build_ios_prod:
          filters:
            tags:
              only: /.*/
            branches:
              ignore: /.*/
      - build_android_stg:
          requires:
            - test
          filters:
            branches:
              only:
                - /feature.*/
                - master
      - build_android_prod:
          filters:
            tags:
              only: /.*/
            branches:
              ignore: /.*/

ワークフローとしては、test成功前提でビルドが動くようにしてます。こうすることで、並列にビルド動くので、かなりの時間削減にもつながります。
また、featureブランチ、masterブランチへのコミットがあった場合にのみbuild_ios_stgとbuild_android_stgが動くようにしてます。
そして、tag pushすることで、今回は省略しているbuild_ios_prodとbuild_android_prodが動くようにしてます。

まとめ

並列でビルドができ、環境構築など面倒ごとの削減ができ、かなり作業効率アップに寄与できたかなと思います。
(コード共有で長い部分はアコーディオンで畳んでるので、気になる方はそちらをくりっくしてください。)

さいごに

ちなみに、私はバックエンドな方のエンジニアなので、あまりReactNativeを触ったことがない中で開発しました。
ありがたいことに、エアクロでは、私のようなフリーランスで入っているエンジニアにも多くの新しい経験をさせてくれます。
皆様もご興味あればぜひぜひ!->採用サイト

Discussion