GitHub ActionsでUnityのAndroid, iOSビルドをやってみる

この記事は [Akatsuki Advent Calendar 2019](https://adventar.org/calendars/3952) 23日目の記事です。
前日は @ShaderError さんによる [Unityでシェーダー描いてみたい](https://qiita.com/ShaderError/items/f349110679a9ec47cca9) でした。
[アカツキ人事がハートドリブンに書く Advent Calendar 2019](https://adventar.org/calendars/4406) もあるのでそちらもぜひ。

## はじめに
以前に仕事でCircleCIを使ってUnityのAndroidとiOSビルドを行なっていたのでその事について書こうかなと思っていたのですが、最近`GitHub Actions`という新機能がGitHubで公開されました。
これがいい感じにCircleCIでUnityビルドをしていた際に起きていた問題を解決していたので、試験的に試してみたまとめが本記事になります。

## 環境
GitHub Actions 12/23時点のワークフロー構文
Unity 2019.3.0f1
MacBook Pro (self-hostedで利用するマシン)

## GitHub Actionsとは
GitHubによるCI/CDツールです。
詳しいことは [GitHub Actions Documentation](https://help.github.com/en/actions) に書いてありますが、`Jenkins`や`CircleCI`などに替わる新しいツールとなるのか(個人的に)気になるサービスです。

## GitHub Actions を導入する
**前提：UnityProjectのリポジトリがGitHubに存在する状態であること**
**利用想定：self-hostedを使ってローカルにあるマシンを`GitHub Actions`で利用する**
前提の状態にするまでの解説は行いませんので、各自作業を行なってください。

GitHubのリポジトリの Setting -> Actions を選択して
`Actions permissions`も`Enable local and third party Actions for this repository`に変更してください
その後に`Self-hosted runners`の`Add runner` を選択
![スクリーンショット 2019-12-22 22.28.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153196/21a39fd0-c5cb-02d8-9209-2f927a7780fc.png)
そうするとrunnerを追加するためのCLIのコマンドが表示されます。
各コマンドを順に実行してください。
実行した後にGitHubのページを再読み込みをすると`Self-hosted runners`にCLIコマンドを実行したPCが表示されていると思います。
これで`GitHub Actions`で`self-hosted`が使えるようになりました。
![スクリーンショット 2019-12-19 0.19.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153196/579905e5-18d2-33f9-91ef-987cb918dd27.png)
その後にGitHubのActionsタブにある`Simple workflow`を使って動作テストをしてみます。
`runs-on`を`self-hosted`にするのを忘れないでください。
変更が完了しコミットをすると、`self-hosted`で`build.yml`が実行されます。
なお、[こちら](https://help.github.com/ja/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#about-yaml-syntax-for-workflows)に記載がありますが、ワークフローファイルは、リポジトリの.github/workflowsディレクトリに保存する必要があるのでご注意ください。
![スクリーンショット 2019-12-18 23.31.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153196/6d882972-03eb-9f7c-e207-da551621d429.png)
実行結果はActionタブから確認できます。
おそらく以下の画像のような結果になっていると思います。
これでローカルにある`self-hosted`に追加されたマシンから実行できるようになりました。
ひとまずこれで基本設定は完了になります。
![スクリーンショット 2019-12-19 0.38.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153196/c0a320d5-b7c6-efda-bfbe-d5aac54202a8.png)




## Unityのビルドスクリプトを作成する

ここからは本格的にビルド処理を実装していきます。

まずはUnityでビルドスクリプトを書きます。

```csharp
using System;
using System.Linq;
using UnityEngine;
using UnityEditor;
using UnityEditor.Build.Reporting;
using System.Collections.Generic;
using UnityEditor.iOS.Xcode;
using UnityEditor.Callbacks;

public class MobileBuild
{
    static string[] GetEnabledScenes()
    {
        return (
                   from scene in EditorBuildSettings.scenes
                   where scene.enabled
                   where !string.IsNullOrEmpty(scene.path)
                   select scene.path
               ).ToArray();
    }

    private static void BuildAndroid()
    {
        // Setting for Android
        EditorPrefs.SetBool("NdkUseEmbedded", true);
        EditorPrefs.SetBool("SdkUseEmbedded", true);
        EditorPrefs.SetBool("JdkUseEmbedded", true);
        EditorUserBuildSettings.androidBuildSystem = AndroidBuildSystem.Gradle;
        PlayerSettings.SetScriptingBackend(BuildTargetGroup.Android, ScriptingImplementation.IL2CPP);

        // Build
        bool result = Build(BuildTarget.Android);

        // Exit Editor
        EditorApplication.Exit(result ? 0 : 1);
    }

    private static void BuildIOS()
    {
        // Setting for iOS
        PlayerSettings.SetScriptingBackend(BuildTargetGroup.iOS, ScriptingImplementation.IL2CPP);
        EditorUserBuildSettings.iOSBuildConfigType = iOSBuildType.Debug;

        // Build
        bool result = Build(BuildTarget.iOS);

        // Exit Editor
        EditorApplication.Exit(result ? 0 : 1);
    }

    private static bool Build(BuildTarget buildTarget)
    {
        // Get Env
        string   outputPath   = GetEnvVar("OUTPUT_PATH");               // Output path
        string   bundleId     = GetEnvVar("BUNDLE_ID");                 // Bundle Identifier
        string   productName  = GetEnvVar("PRODUCT_NAME");              // Product Name
        string   companyName  = GetEnvVar("COMPANY_NAME");              // Company Name
        
        outputPath = AddExpand(buildTarget, outputPath);
        
        Debug.Log("[MobileBuild] Build OUTPUT_PATH :" + outputPath);
        Debug.Log("[MobileBuild] Build BUILD_SCENES :" + String.Join("", GetEnabledScenes()));

        // Player Settings
        BuildOptions buildOptions;
        buildOptions = BuildOptions.Development | BuildOptions.CompressWithLz4;
        
        if (!string.IsNullOrEmpty(companyName)) { PlayerSettings.companyName = companyName; }

        if (!string.IsNullOrEmpty(productName)) { PlayerSettings.productName = productName; }

        if (!string.IsNullOrEmpty(bundleId)) { PlayerSettings.applicationIdentifier = bundleId; }
        
        // Build
        var report = BuildPipeline.BuildPlayer(GetEnabledScenes(), outputPath, buildTarget, buildOptions);
        var summary = report.summary;

        // Build Report
        for (int i = 0; i < report.steps.Length; ++i)
        {
            var step = report.steps[i];
            Debug.Log($"{step.name} Depth:{step.depth} Duration:{step.duration}");

            for (int d = 0; d < step.messages.Length; ++d)
            {
                Debug.Log($"{step.messages[d].content}");
            }
        }

        if (summary.result == BuildResult.Succeeded)
        {
            Debug.Log("<color=white>[MobileBuild] Build Success : " + outputPath + "</color>");
            return true;
        }
        else
        {
            Debug.Assert(false, "[MobileBuild] Build Error : " + report.name);
            return false;
        }
    }

    private static string GetEnvVar(string pKey)
    {
        return Environment.GetEnvironmentVariable(pKey);
    }

    private static string AddExpand(BuildTarget buildTarget, string outputPath)
    {
        switch (buildTarget)
        {
            case BuildTarget.Android :
                outputPath += ".apk";
                break;
        }

        return outputPath;
    }
}
```

大まかに書くとこのような感じになるかと思います。
ビルド後に実行する処理等は書いていませんので、必要に応じて追加してください。

ローカルで上記のスクリプトが動作するか以下のコマンドをCLIで確認してください。

```sh
# 適宜書き換えてください
export COMPANY_NAME=""
export PRODUCT_NAME=""
export BUNDLE_ID=""
export OUTPUT_PATH =""

/Applications/Unity/Hub/Editor/2019.3.0f1/Unity.app/Contents/MacOS/Unity -quit -batchmode -nographics -silent-crashes -logFile -projectPath ./ -executeMethod MobileBuild.BuildIOS -buildTarget iOS

/Applications/Unity/Hub/Editor/2019.3.0f1/Unity.app/Contents/MacOS/Unity -quit -batchmode -nographics -silent-crashes -logFile -projectPath ./ -executeMethod MobileBuild.BuildAndroid -buildTarget Android
```

CLIが実行できたのであればいよいよ`GitHub Actions`を使った話へ進みます

## GitHub Actions で Unity のビルドを行う
### Android Build
まずはAndroidのビルドを行います。
Unity2019からAndroidのNDKやJDKがインストール時に追加できるようになりました！
なのでNDKやJDKの設定は各自でお願いします（いい時代になりましたね）

早速`build.yml`ファイルを書き換えましょう

```yml
name: ApplicationBuild

on: [push, pull_request]

env:
  OUTPUT_PATH: ""
  BUNDLE_ID: ""
  PRODUCT_NAME: ""
  COMPANY_NAME: ""
  UNITY_VERSION: 2019.3.0f1
jobs:
  build:
    runs-on: self-hosted
    steps:
    - uses: actions/checkout@v2
    - name: Android Build
      run: |
        /Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity -quit -batchmode -nographics -silent-crashes -logFile \
          -projectPath ./ -executeMethod MobileBuild.BuildAndroid -buildTarget Android

```

Androidビルドをするだけのシンプルな処理です。
環境変数は各自で適切に入力してください。

いくつかを説明を軽くしますと、
#### name
`name:`はワークフローの名前になります。
この名前がリポジトリのアクションページにワークフローに表示されます。
![スクリーンショット 2019-12-23 14.04.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153196/013aaf8d-07d9-7b00-a806-ab3a562860f9.png)
#### on
`on:`はワークフローをトリガーするGitHubイベントの名前です。
イベントの種類は多くあり[こちら](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/events-that-trigger-workflows)にドキュメントとしてまとめてあります。
今回は`push`と`pull_request`をトリガーにしています。

#### env
1. `env`はワークフローの全てのジョブから利用できる環境変数を定義します。
1. `jobs.<job_id>.env`は`<job_id>`ジョブから利用できる環境変数を定義します。
1. `jobs.<job_id>.steps.env`はステップから利用できる環境変数を定義します。

先ほどの`build.yml`は 2. の`env`を使っています。
この後のiOSビルドでも使いますからね。

#### jobs.job_id.steps.uses
ジョブでステップの一部として実行されるアクションを選択します。
`actions/checkout`はワークフローで使用できる標準アクションで、`v2`を指定することでチェックアウトアクションの`v2`を利用するという設定になります。(標準のアクションは[こちら](https://github.com/actions)にまとまっています)

#### jobs.job_id.steps.run
オペレーティングシステムのシェルを使用してコマンドラインプログラムを実行します。
さらに`shell`キーワードを使用すると、環境のOSのデフォルトシェルを上書きできます。


### iOS Build

続いてiOSビルドです。
今回は~~時間がなくなったのと複雑になるので~~、ipaファイルをビルドするところまでは行わず、xcodeprojをビルドするところまで行います。
では、`build.yml`を書き換えましょう。

```yml
name: ApplicationBuild

on: [push, pull_request]

env:
  OUTPUT_PATH: ""
  BUNDLE_ID: ""
  PRODUCT_NAME: ""
  COMPANY_NAME: ""
  UNITY_VERSION: "2019.3.0f1"
jobs:
  android-build:
    runs-on: self-hosted
    steps:
    - uses: actions/checkout@v2
      with:
        path: android
    - name: Android Build
      run: |
        /Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity -quit -batchmode -nographics -silent-crashes -logFile \
          -projectPath ./android -executeMethod MobileBuild.BuildAndroid -buildTarget Android
  ios-build:
    runs-on: self-hosted
    steps:
    - uses: actions/checkout@v2
      with:
        path: ios
    - name: iOS Build
      run: |
        /Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity -quit -batchmode -nographics -silent-crashes -logFile \
          -projectPath ./ios -executeMethod MobileBuild.BuildIOS -buildTarget iOS

```

書き方が変わった箇所がありますね。
`actions/checkout`に`with:`キーワードを使って`path:`の設定を渡しています。
こうしている理由は、AndroidとiOSで利用するフォルダを分けてインポート時間やビルド時間の高速化するためです。

この状態でpushしてみましょう。
`self-hosted`に登録したサーバーが動作してビルドが実行されるはずです。
AndroidとiOSのビルドが成功すると以下のような画面になります。
![スクリーンショット 2019-12-23 16.58.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153196/13e5172c-d9b8-ab49-bda0-82893f4407d9.png)

## 暗号化されたシークレットを利用する

実際に運用しようとなると、`yml`に書きたくない情報を渡したいケースがあると思います。
その場合は GitHub のリポジトリの Setting -> Secret ページへいき `Add a new secret` をクリック。
シークレットとは、暗号化された環境変数のことです。
`Name`と`Value`をそれぞれ入力します。
![スクリーンショット 2019-12-23 17.10.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153196/6622dec4-5f9a-bc98-5dd3-594c34417f32.png)
ここで設定した値をワークフローで利用するには

```yml
env:
  SOME_PARAM: ${{ secrets.SOME_PARAM }}
```
のように記述することで可能です。
これで`password`や`TOKEN`の情報を渡すことができますね！
なお、シークレットの制限として
1. ワークフローで最大100のシークレットを持てる
2. シークレットの容量は最大64KB
の2点があります。
参考： [暗号化されたシークレットの作成と利用](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)

## 終わりに
個人的にとてもいいツールだと感じました。
具体的には以下の点に可能性があると思います
1. マシンスペックをこちらで自由にカスタマイズできる（self-hostedの場合）
2. CI/CDがGitHubで完結する
3. Jenkinsから解放される（アセットバンドルをどうするのかという問題はありますが・・・）

他にもCircleCI(MacOS)と違ってUnityのインストールを毎回行う必要がなかったりと比較すると優れている点が多いなという印象です。
簡単な導入まででしたが、以上になります。
参考になれば幸いです。