CI初心者がfastlaneでアプリ申請の半自動化を行うまで

# 概要

[グレンジ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/grenge) 12日目の記事です。
本稿は業務改善系の記事になります。

# 経緯

所属プロジェクトの業務フローにアプリ申請作業があります。
その中でいくつか改善できそうな問題点がありました。

## 問題点

- 申請用パッケージ作成に必要な各種ツールの操作が手作業
- ダブルチェック体制のため、エンジニア2人が長時間拘束される
- 新しくチームへ参加した人にオペレーションを伝授する場合、エンジニア3人体制になる
- 業務時間の大半が持っていかれる

申請が手作業なのは珍しくないとは思っていますが、機械的な手続きを自動化すればエンジニア2〜3人が長時間拘束される状況は改善できそうだなと思いました。
今回はfastlaneの導入をもって機械的な手続きの自動化を行いました。

## fastlaneとは

fastlaneはアプリ申請に関わるあらゆる手続きがアクションとして実行できるようになっているRuby製の開発支援ツールです。
2015年くらいから関連記事があるので割と歴史のあるツールのようですが、今でも定期的にアップデートがあるので安心して使えそうです。

[https://docs.fastlane.tools/](https://docs.fastlane.tools/)

# やりたかったこと

- アプリのビルド〜申請を自動化
- iOS証明書管理も自動化（fastlane match）
- 進捗をSlackで通知

# 実際にできたこと

実際にできたことは部分的です。
今後もマイルストーンを設定して改善していけたらと思っています。

- アプリのビルドは自動化、申請は手動（iOSはxcworkspace書き出しまで）
- 証明書は手動管理
- 特定環境下のアプリはAppCenterのアップロードまで自動化
- 進捗をSlackで通知

# fastlane導入

bundlerを使ってfastlaneのインストールをしました。

## bundlerのインストール

```shell
$ sudo gem install bundler
```

## プロジェクトルートにGemfile作成

```shell
$ cd /path/to/projectRoot
$ touch Gemfile
$ vi Gemfile
```

```vim:Gemfile
source "https://rubygems.org"
gem "fastlane"
gem "dotenv" # ローカル環境変数を使用するため
gem "cocoapods"
gem "xcode-install" # Xcodeバージョン指定するため
```
所属プロジェクトではdotenv、cocoapods、xcode-installなども追加しています。

## インストール

Gemfileを定義した場所でインストールコマンドを実行します。

```shell
$ bundle install
```

## Rubyのエラー回避

上記コマンド実行時にエラーが出る場合、rubyのバージョンを改める必要がある模様です。
macOS Catalinaでも同様のエラーが発生しました。
rbenvから2.6.3をインストールしてパスを通してあげたら問題なくインストールできました。

[macOS Mojave 'ruby/config.h' file not found](https://stackoverflow.com/questions/53135863/macos-mojave-ruby-config-h-file-not-found)

# 実装

## 共通定義
Configファイルを作って、共通で使いたい定数のようなものはまとめるようにしました。

```ruby:Config
module Unity
    def self.path
        '/path/to/Unity.app/Contents/MacOS/Unity' # Unityのパス
    end
end

module Xcode
    def self.version
        '12.3'
    end
    def self.ipa_name
        'exampleApp'
    end
end

module Slack
    def self.url
        'https://path/to/slack/webhook/url' # SlackのWebhook URL
    end
    def self.channel
        '#fastlane_notification' # Slackのチャンネル名
    end
    def self.icon_url
        'https://path/to/fastlane_icon.png' # アイコンURL（一応記述してるけどSlack側の設定を見ているような・・？）
    end
end

module Git
    def self.base_branch
        'develop'
    end
end
```

以下のように記述することでConfigファイルを読み込めるようです。

```ruby:Fastfile
load './Config'
```

## Slack通知

fastlaneはiosとandroidプラットフォームに分けてレーンを定義する必要があるようです。
各レーンの開始と終了の成功可否を監視したかったため、before_eachとafter_eachにアクションを書きました。

```ruby:Fastfile
load './Config'

before_all do
  ENV["SLACK_URL"] = "#{Slack.url}"
  ENV["FL_SLACK_ICON_URL"] = "#{Slack.icon_url}"
  ENV["FL_SLACK_CHANNEL"] = "#{Slack.channel}"
end

error do |lane, exception|
  slack(
    message: "エラーが発生しました",
    success: false,
    payload: {
      "Output" => exception.to_s
    }
  )
end

platform :ios do

  before_each do |lane, options|
    # optionsがArrayで渡ってきた場合、versionとbuildNumberをチェック
    if !options.nil? && options.is_a?(Array) && options.size > 0 && options[0].include?(:version)
      slack(
        message: "#{lane} を開始します",
          payload: {
            "Build Date" => Time.new.to_s,
            "version" => "#{options[0][:version]}",
            "buildNumber" => "#{options[0][:buildNumber]}" 
          }
      )
    else
      slack(
        message: "#{lane} を開始します",
          payload: {
            "Build Date" => Time.new.to_s,
          }
      )
    end
  end

  after_each do |lane, options|
    slack(
      message: "#{lane} が完了しました",
    )
  end

# 中略

end
```

## Unityのビルド

optionsという引数を使って、ビルド環境やバージョン、出力先を指定できるようにしています。
ビルド後、cocoapodsアクションでpod installを実行しています。

```ruby
# 中略

platform :ios do

# 中略

  desc 'Productionパッケージ作成'
  lane :production do |options|
    options[:env] = 'production'
    options[:version] = UI.input("バージョンを入力してください: ") if options[:version].nil?
    options[:buildNumber] = UI.input("ビルド番号を入力してください: ") if options[:buildNumber].nil?
    options[:out_path] = "path/to/output/"

    # Switch Platform実行
    switch_unity(options)

    # Reimport Assets実行
    reimport_assets(options)

    # ビルド実行
    build_unity(options)

    # ProductionはUnityのビルドまで（Xcodeの作業は手動で行う）
  end

  desc "Switch Platform to iOS"
  private_lane :switch_unity do |options|
    # 割愛：UnityのSwitchPlatformメソッドをバッチモードで呼び出す
  end

  desc "Reimport Assets"
  private_lane :reimport_assets do |options|
    # 割愛：Assets配下のReimport Assetsをバッチモードで呼び出す
  end

  desc "Build Unity (iOS)"
  private_lane :build_unity do |options|
    # 割愛：パッケージのビルドメソッドをバッチモードで呼び出す

    # cocoapods install
    cocoapods(
      clean_install: true,
      podfile: "#{options[:out_path]}/Podfile"
    )
  end

# 中略

end
```

## ipaのビルド

特定環境下のみXcodeのビルドも行っています。
xcversionアクションでXcodeのバージョンを指定しています。
gymアクションでipaのビルドを行っています。

```ruby
# 中略

platform :ios do

# 中略

  desc "ipaのビルド"
  private_lane :build_ipa do |options|
    # xcodeバージョン指定
    xcversion(version: "#{Xcode.version}")

    # ビルド
    gym(
      workspace: "#{options[:out_path]}Unity-iPhone.xcworkspace",
      configuration: "Release",
      scheme: "Unity-iPhone",
      silent: true,
      clean: true,
      archive_path: "#{options[:out_path]}Unity-iPhone.xcarchive",
      output_directory: "#{options[:out_path]}",
      output_name: "#{Xcode.ipa_name}.ipa",
      export_method: "development",
      export_options: {
        signingStyle: "manual",
        provisioningProfiles: {
          "#{ENV['APP_IDENTIFIER']}" => "#{ENV['APP_PROVISIONING_PROFILE_UUID']}",
          "#{ENV['NOTI_BUNDLE_ID']}" => "#{ENV['NOTI_PROVISIONING_PROFILE_UUID']}"
        }
      }
    )
  end

  desc "ipaファイルの再署名（Xcode12.4以下で端末にインストールできない問題の対応）"
  private_lane :resign_ipa do |options|
    # 解凍先はxarchiveやInfo.plistの格納先に合わせる
    sh "unzip -o #{options[:out_path]}#{Xcode.ipa_name}.ipa"
    sh "codesign -dvvvvv ./Payload/#{Xcode.ipa_name}.app"
    sh "codesign -s '#{ENV['SIGNING_IDENTIFY']}' -f --preserve-metadata --generate-entitlement-der ./Payload/#{Xcode.ipa_name}.app"
    sh "zip -ru #{Xcode.ipa_name}_resign.ipa Payload"
    sh "rm -rf Payload/"
    sh "mv #{options[:out_path]}#{Xcode.ipa_name}.ipa #{options[:out_path]}#{Xcode.ipa_name}_base.ipa"
    sh "mv #{Xcode.ipa_name}_resign.ipa #{options[:out_path]}#{Xcode.ipa_name}.ipa"
  end

# 中略

end
```

## AppCenterへのアップロード

AppCenterアクションを定義したプラグインがあったので、そちらを利用しました。

[https://github.com/microsoft/fastlane-plugin-appcenter](https://github.com/microsoft/fastlane-plugin-appcenter)

```ruby
# 中略

platform :ios do

# 中略

  desc "AppCenterへアップロード"
  private_lane :upload_to_appcenter do |options|
    UI.user_error!("optionsに必要なパラメータがありません") if options.nil?
    appcenter_upload(
      api_token: "#{ENV['APPCENETR_TOKEN']}",
      owner_name: "#{ENV['APPCENTER_OWNER_NAME']}",
      owner_type: "#{ENV['APPCENTER_OWNER_TYPE']}", # Default is user - set to organization for appcenter organizations
      app_name: "example_app_ios",
      file: "#{options[:out_path]}#{Xcode.ipa_name}.ipa",
      release_notes: "#{options[:version]}",
      notify_testers: false # Set to false if you don't want to notify testers of your new release (default: `false`)
    )
  end
end
```

# 結果

今まで細かい工程をメモしながら行っていた作業が、以下コマンドを打つだけで概ね完了するようになりました！
（実行前にgitコマンド打ったりはしてます）

```shell
$ bundle exec fastlane ios production
```

2〜3人のエンジニアを長時間拘束していた作業を、プロジェクト独自の知見を持たない人でも実行できて短時間で完了するようにできました。

# まとめ

今まで誰かが構築したCIツールの操作をすることはあっても、実際にコードを書いたりフローを考えたりすることが無かったのでとても勉強になりました。

ただ冒頭で前述した通り、まだまだ「自動化」「効率化」とは言い難く、課題は残っています。

- 申請準備の半自動化はできたが、完全な自動化ではない
- ダブルチェックの必要な手作業が残っている（Xcodeまわり）
- iOSとAndroidをシーケンシャルもしくは並列でビルドできていない
- ターミナルからiOSとAndroidのビルドコマンドを手打ちしてる

作った人がいなくても回るような仕組みを作れるまで引き続き改善してきたいと思っています。