iOS
TravisCI
Swift
fastlane

TravisCIとfastlaneでiOSアプリのCI【完成版】

前回の記事からはや3ヶ月・・・
TravisCIとfastlaneでiOSアプリのCI【途中】
その間何をやっていたかというと、、、特に何もしていませんでした:smirk_cat:
つい最近またCI熱が高まり、ついに使えるところまで持っていけたので、まとめて記事にします:star2:
前回の記事と方針が変わっているところもありますが、それはご愛嬌:yum:

この記事で実現できること、前提条件

ちょっと量が多いですが、大事なことなので書いておきます。
特にビルドコンフィギュレーションとプロビジョニングファイルについては把握しておかないと、CI上でアーカイブする時にCode Signing Errorで苦しむことになります(僕はなりました)。

AppleアカウントとかバンドルIDとかチームID

一つしか使いません。開発環境と本番環境で変えるとか、Developer CenterとiTunesConnectで違うアカウント使うとか、そういったことは対象外になります。

環境

開発環境と本番環境の2つがあります。アプリ側はビルドコンフィギュレーション(厳密にはフラグ)でつなぎ先を変えます。

こんなコードが含まれています
static var domain: String {
    #if DEBUG
        return "https://開発ドメイン/"
    #else
        return "https://本番ドメイン/"
    #endif
}

ビルドコンフィギュレーション

Xcodeで使用するビルドコンフィギュレーションは以下の4つとします。

名前 フラグ 環境 プロビジョニングファイル種別
Debug DEBUG 開発 Development
AdHoc_dev DEBUG 開発 AdHoc
AdHoc_dis なし 本番 AdHoc
Release なし 本番 AppStore

Gitブランチ

CIで使用するブランチは以下の3つとします。それ以外のブランチではCIが走らないようにします。

ブランチ名 ビルドコンフィギュレーション 環境 プロビジョニングファイル種別 デプロイ先
deploy_debug AdHoc_dev 開発 AdHoc DeployGate
deploy_release AdHoc_dis 本番 AdHoc DeployGate
release Release 本番 AppStore iTunesConnect

開発者証明書(certificate)

手動で作ってリポジトリに含めます。CI上ではリポジトリに含まれる証明書を使います。fastlaneのmatchcertは使いません。

プロビジョニングファイル

fastlaneのsighを使ってダウンロード/インストールします。Xcode上ではmanualで指定します。XcodeのAutomatically manage signingはチェックを外します。
TargetのGeneralタブは以下のようになります。
Signing.png

fastlaneの実行

fastlaneは直で実行します。bundleは使いません。

メタデータ/スクリーンショットの管理

管理はしません。deliverでiTunesConnectにアップロードするのはビルドだけです。

デプロイツールと通知ツール

DeployGateとslackを使います。その他のツールについては扱いません。

Travis

リポジトリと連携

自身のアカウントページへ行き、下の方にスクロールすると、自分がアクセスできるリポジトリ一覧が表示されます。CIに載せたいリポジトリのスイッチをONにします。そして設定画面へ。
Travis_repository.png

設定

環境変数にFASTLANE_PASSWORDを追加します。値は使用するAppleアカウントのパスワードです。
後はお好みで。
Travis_setting.png

プロジェクトルートに.travis.ymlを追加

エディタなどでファイルを生成し、プロジェクトルートに配置します。中身は以下の通り。

.travis.yml
language: objective-c
osx_image: xcode9.1

notifications:
  slack: slackで発行したトークン

cache: cocoapods

branches:
  only:
    - deploy_debug
    - deploy_release
    - release

script:
  - if [ $TRAVIS_BRANCH == deploy_debug  ] && [ $TRAVIS_PULL_REQUEST == false ]; then fastlane deploy_debug; else true; fi
  - if [ $TRAVIS_BRANCH == deploy_release  ] && [ $TRAVIS_PULL_REQUEST == false ]; then fastlane deploy_release; else true; fi
  - if [ $TRAVIS_BRANCH == release  ] && [ $TRAVIS_PULL_REQUEST == false ]; then fastlane release; else true; fi

言語とXcodeのバージョン指定

.travis.ymlから抜粋
language: objective-c
osx_image: xcode9.1

言語はSwiftであってもobjective-cと指定します。
xcodeのバージョンは使いたいものを指定してください。
参考: Building an Objective-C or Swift Project

通知設定

.travis.ymlから抜粋
notifications:
  slack: slackで発行したトークン

結果をslackで通知したいので、トークンを設定します。
参考: Configuring Build Notifications

キャッシュ

.travis.ymlから抜粋
cache: cocoapods

TravisはルートディレクトリにPodfileがある場合、自動でpod installを実行してくれます。
参考: Building an Objective-C or Swift Project(Dependency Management)
ですが、毎回pod installされると時間かかるのでキャッシュさせます。
参考: Caching Dependencies and Directories

ブランチを制限

.travis.ymlから抜粋
branches:
  only:
    - deploy_debug
    - deploy_release
    - release

今回CIを走らせたいブランチは3つだけなので、それらを指定します。
参考: Customizing the Build(Building Specific Branches)

fastlaneを実行

.travis.ymlから抜粋
script:
  - if [ $TRAVIS_BRANCH == deploy_debug  ] && [ $TRAVIS_PULL_REQUEST == false ]; then fastlane deploy_debug; else true; fi
  - if [ $TRAVIS_BRANCH == deploy_release  ] && [ $TRAVIS_PULL_REQUEST == false ]; then fastlane deploy_release; else true; fi
  - if [ $TRAVIS_BRANCH == release  ] && [ $TRAVIS_PULL_REQUEST == false ]; then fastlane release; else true; fi

ブランチに応じてfastlaneのlane(後述)を実行します。
$TRAVIS_BRANCH$TRAVIS_PULL_REQUESTはTravisの環境変数で、それぞれブランチ名とPRか否かを取得できます。
参考: Environment Variables

fastlane

initを実行

プロジェクトルートでfastlane initを実行します。
使うアカウントやプロジェクトによって若干の違いはありますが、ガイドに従ってアカウントやプロジェクトファイルorワークスペース、スキームの設定をします。deliverでメタデータやスクリーンショットを管理するための処理も入ってしまいますが、今回は使いません。

実行例(途中省略、一部改変)
$ fastlane init
[13:56:15]: Get started using a Gemfile for fastlane https://docs.fastlane.tools/getting-started/ios/setup/#use-a-gemfile
[13:56:18]: Detected iOS/Mac project in current directory...
[13:56:18]: This setup will help you get up and running in no time.
[13:56:18]: fastlane will check what tools you're already using and set up
[13:56:18]: the tool automatically for you. Have fun! 
[13:56:18]: $ xcodebuild -list -workspace ./app.xcworkspace
...
Select Scheme:
1. hoge
2. fuga
3. Alamofire
5. Pods-hoge
?  1 // スキームが複数ある場合は選ぶ
[13:56:21]: $ xcodebuild -showBuildSettings -workspace ./app.xcworkspace -scheme hoge
...
[13:56:23]: Your Apple ID (e.g. fastlane@krausefx.com): some@account.com // IDを入力
[13:56:29]: Verifying that app is available on the Apple Developer Portal and iTunes Connect...
[13:56:29]: Starting login with user 'アカウントID'

+----------------+--------------------------------------+
|                    Detected Values                    |
+----------------+--------------------------------------+
| Apple ID       | アカウントID                           |
| App Name       | アプリ名                              |
| App Identifier | バンドルID                            |
| Workspace      | パス                                  |
+----------------+--------------------------------------+

[13:56:40]: Note: If the values above are incorrect, it is possible the wrong scheme was selected
[13:56:40]: Please confirm the above values (y/n)
y // 問題無ければy
[13:56:49]: Created new file './fastlane/Appfile'. Edit it to manage your preferred app metadata information.
[13:56:49]: Loading up 'deliver', this might take a few seconds
[13:56:49]: Login to iTunes Connect (アカウントID)
[13:56:52]: Login successful

+--------------------------------------+------------------------+
|                    deliver 2.66.0 Summary                     |
+--------------------------------------+------------------------+
| run_precheck_before_submit           | false                  |
| screenshots_path                     | ./fastlane/screenshots |
| metadata_path                        | ./fastlane/metadata    |
| username                             | アカウントID             |
| app_identifier                       | バンドルID              |
| edit_live                            | false                  |
| platform                             | ios                    |
| skip_binary_upload                   | false                  |
| skip_screenshots                     | false                  |
| skip_metadata                        | false                  |
| skip_app_version_update              | false                  |
| force                                | false                  |
| submit_for_review                    | false                  |
| automatic_release                    | false                  |
| dev_portal_team_id                   | チームID                |
| overwrite_screenshots                | false                  |
| precheck_default_rule_level          | warn                   |
| ignore_language_directory_validatio  | false                  |
| n                                    |                        |
| precheck_include_in_app_purchases    | true                   |
+--------------------------------------+------------------------+

...
[13:59:26]: Successfully downloaded all existing screenshots
[13:59:26]: Successfully created new Deliverfile at path './fastlane/Deliverfile'
[13:59:26]: 'snapshot' not enabled.
[13:59:26]: 'cocoapods' enabled.
[13:59:26]: 'carthage' not enabled.
[13:59:26]: Created new file './fastlane/Fastfile'. Edit it to manage your own deployment lanes.
[13:59:26]: fastlane will collect the number of errors for each action to detect integration issues
[13:59:26]: No sensitive/private information will be uploaded
[13:59:26]: Learn more at https://github.com/fastlane/fastlane#metrics
[13:59:26]: Successfully finished setting up fastlane

Appfile

fastlane initで自動生成されます。アカウント情報を正しく入力できていれば変更する必要はありません。もし間違っている場合は手動で正しいIDを入力してください。

Fastfile

fastlane initで自動生成されます。fastlaneが実行する処理をlaneという単位で記述します。
中身は以下の通り。

fastfile
fastlane_version "2.61.0"

default_platform :ios

platform :ios do

    BUNDLE_ID  = "バンドルID"
    SCHEME     = "スキーム"
    PLIST_PATH = "info.plistのパス"
    VERSION    = get_info_plist_value(path: PLIST_PATH, key: "CFBundleShortVersionString")
    BUILD      = get_info_plist_value(path: PLIST_PATH, key: "CFBundleVersion")

    DEPLOYGATE_API_TOKEN = "APIトークン"
    DEPLOYGATE_USER      = "ユーザ/グループ名"

    before_all do
        ENV["SLACK_URL"] = "Incoming WebHooksのURL"
    end

    desc "archive release and upload to iTunesConnect"
    lane :release do
        import_cert
        sigh
        gym(
            scheme: SCHEME,
        )
        deliver(
            skip_screenshots: true,
            skip_metadata: true,
        )
        release_tag
    end

    desc "archive release and deploy by DeployGate"
    lane :deploy_release do
        import_cert
        sigh(
            adhoc: true,
        )
        gym(
            scheme: SCHEME,
            configuration: "AdHoc_dis",
        )
        message = "本番環境 v#{VERSION}(#{BUILD})"
        deploygate(
            api_token: DEPLOYGATE_API_TOKEN,
            user: DEPLOYGATE_USER,
            message: message,
        )
        slack(
            message: message
        )
    end

    desc "archive debug and deploy by DeployGate"
    lane :deploy_debug do
        import_cert
        sigh(
            adhoc: true,
        )
        gym(
            scheme: SCHEME,
            configuration: "AdHoc_dev",
        )
        message = "開発環境 v#{VERSION}(#{BUILD})"
        deploygate(
            api_token: DEPLOYGATE_API_TOKEN,
            user: DEPLOYGATE_USER,
            message: message,
        )
        slack(
            message: message
        )
    end

    desc "add release tag"
    private_lane :release_tag do |tag|
        add_git_tag(
            tag: "v#{VERSION}",
            force: true,
        )
        push_git_tags(
            force: true
        )
    end

    desc "import cert"
    private_lane :import_cert do
        next unless Helper.is_ci?
        KEYCHAIN_NAME     = "キーチェーン名.keychain"
        KEYCHAIN_PASSWORD = "パスワード"
        create_keychain(
            name: KEYCHAIN_NAME,
            password: KEYCHAIN_PASSWORD,
            default_keychain: true,
            unlock: true,
            timeout: 3600,
        )
        import_certificate(
            certificate_path: "./certs/distribution.p12",
            keychain_name: KEYCHAIN_NAME,
            keychain_password: KEYCHAIN_PASSWORD,
        )
    end

    error do |lane, exception|
        next unless Helper.is_ci?
        slack(
            message: exception.message,
            success: false
        )
    end
end

おやくそく

fastfileから抜粋
fastlane_version "2.61.0"

default_platform :ios

platform :ios do
    ....
end

バージョン、プラットフォームの指定を書きます。

使いまわす変数の宣言

fastfileから抜粋
BUNDLE_ID  = "バンドルID"
SCHEME     = "スキーム"
PLIST_PATH = "info.plistのパス"
VERSION    = get_info_plist_value(path: PLIST_PATH, key: "CFBundleShortVersionString")
BUILD      = get_info_plist_value(path: PLIST_PATH, key: "CFBundleVersion")

DEPLOYGATE_API_TOKEN = "APIトークン"
DEPLOYGATE_USER      = "ユーザ/グループ名"

複数のlaneで使う変数は予め定義しておくと便利です。
get_info_plist_valueは名前の通りinfo.plistの値を取得できる関数です。書いていて思いましたがバンドルIDもこれで取得できますね。。。

参考:
get_info_plist_value

全てのlaneの前に実行される処理

fastfileから抜粋
before_all do
    ENV["SLACK_URL"] = "Incoming WebHooksのURL"
end

fastlaneの結果もslackに流したいので、URLをセットしておきます。

エラー時の処理

fastfileから抜粋
error do |lane, exception|
    next unless Helper.is_ci?
    slack(
        message: exception.message,
        success: false
    )
end

エラーが起きたときはその内容をslackに流します。
ただし、ローカルでfastlaneを実行する時はすぐにログを見れるので、CIでの実行時のみslackに通知します。
Helper.is_ci?はCIでの実行か否かを返すアクションです。

参考:
slack
is_ci

キーチェンの生成と開発者証明書の取り込み

fastfileから抜粋
desc "import cert"
private_lane :import_cert do
    next unless Helper.is_ci?
    KEYCHAIN_NAME     = "キーチェーン名.keychain"
    KEYCHAIN_PASSWORD = "パスワード"
    create_keychain(
        name: KEYCHAIN_NAME,
        password: KEYCHAIN_PASSWORD,
        default_keychain: true,
        unlock: true,
        timeout: 3600,
    )
    import_certificate(
        certificate_path: "./certs/distribution.p12",
        keychain_name: KEYCHAIN_NAME,
        keychain_password: KEYCHAIN_PASSWORD,
    )
end

CI環境は開発者証明書が使える状態になっていないので、キーチェーンの生成とそのキーチェーンへ開発者証明書を取り込んでやります。
private_laneは外部からの呼び出し不可なlaneの宣言です。このlaneは他のlaneから呼び出して使うのでprivateにしています。
create_keychainはキーチェーンを生成するアクションです。オプションでunlock: trueを渡してやらないと、CIで実行した時にキーチェーンへのアクセス許可を求めるポップアップが出てしまい、タイムアウトしてしまいます。
import_certificateはキーチェーンに開発者証明書を取り込むアクションです。上の例は証明書にパスワードを設定していないのですが、もしパスワードを設定している場合はcertificate_passwordというオプションでパスワードを渡してやる必要があります。
ローカルでは既にキーチェーンに開発者証明書が取り込まれている前提なので、この処理は実行しません。

参考:
create_keychain
import_certificate

開発環境ビルドの配布

fastfileから抜粋
desc "archive debug and deploy by DeployGate"
lane :deploy_debug do
    import_cert
    sigh(
        adhoc: true,
    )
    gym(
        scheme: SCHEME,
        configuration: "AdHoc_dev",
    )
    message = "開発環境 v#{VERSION}(#{BUILD})"
    deploygate(
        api_token: DEPLOYGATE_API_TOKEN,
        user: DEPLOYGATE_USER,
        message: message,
    )
    slack(
        message: message
    )
end

以下の流れになります。
1. 開発者証明書の取り込み(import_cert)
2. プロビジョニングファイルのダウンロード/インストール(sigh)
3. ipaファイル生成(gym)
4. DeployGateで配信(deploygate)
5. slackに通知(slack)
import_certは上で定義したprivate_laneです。キーチェーンの生成と証明書の取り込みを行います。
sighはプロビジョニングファイルのダウンロード/インストールを行うアクションです。fastlane initで入力したアカウント/バンドルIDに紐づくプロビジョニングファイルを、無ければ作成し、あればそれを取得します。デフォルトでAppStoreのプロビジョニングファイルを取得しようとするので、adhoc: trueをつけて、AdHocのものを取得させます。
gymはipaを生成するアクションです。fastlane init時に設定したプロジェクトorワークスペースとスキームでアーカイブします。そのはずなんですが、何故かスキームをうまく見つけてくれなかったので、schemeオプションで指定しています。CIがスキームを見つけられるように、スキームはsharedにしておく必要があります。デフォルトのビルドコンフィギュレーションはReleaseです。今回はAdHoc_devでアーカイブさせたいので、configurationオプションで指定します。
deploygateはDeployGateにデプロイするアクションです。api_token, user, messageをオプションで指定します。
いずれかのアクションでエラーが発生すると、そこでlaneは中断され、errorが呼び出されます。

参考:
sigh
gym
deploygate

本番環境ビルドの配布

fastfileから抜粋
desc "archive release and deploy by DeployGate"
lane :deploy_release do
    import_cert
    sigh(
        adhoc: true,
    )
    gym(
        scheme: SCHEME,
        configuration: "AdHoc_dis",
    )
    message = "本番環境 v#{VERSION}(#{BUILD})"
    deploygate(
        api_token: DEPLOYGATE_API_TOKEN,
        user: DEPLOYGATE_USER,
        message: message,
    )
    slack(
        message: message
    )
end

ほとんど開発環境ビルドの配布と同じです。違うのはアーカイブのときのコンフィギュレーションがAdHoc_disであることと、メッセージのみです。

リリースビルドのアップロード

fastfileから抜粋
desc "archive release and upload to iTunesConnect"
lane :release do
    import_cert
    sigh
    gym(
        scheme: SCHEME,
    )
    deliver(
        skip_screenshots: true,
        skip_metadata: true,
    )
    release_tag
end

desc "add release tag"
private_lane :release_tag do |tag|
    add_git_tag(
        tag: "v#{VERSION}",
        force: true,
    )
    push_git_tags(
        force: true
    )
end

アーカイブまでは上の2つとほとんど同じです。今回はAppStoreのプロビジョニングファイルが必要なので、sighadhocオプションはつけません。
deliverはiTunesConnectにアップロードするためのアクションです。スクリーンショットやメタデータもアップロードできるのですが、今回はビルドのアップロードのみ使用したいので、その他の機能はオプションで無効化します。
release_tagはタグ付けしてpushするlaneです。githubにpushするので、権限がある鍵をTravisに登録しておく必要があります。

参考:
deliver
add_git_tag
push_git_tags

おわりに

今まで手動でアーカイブしていたものが、特定のブランチにプッシュしたらそのうちDeployGateで配信されたり、iTunesConnectにアップロードされるというのは、非常に感動します:sob:
ただ、割とビルドに時間がかかるみたいなので、もっとビルド時間を短縮できたらなと思います:wrench:
証明書とプロビジョニングファイルはmatchを使うともう少しラクになりそうなので、そちらも挑戦してみたいです:muscle: