白黒羊

Unity Cloud Build ▶︎ Discord ▶︎ TestFlight 自動化

TestFlightにいちいち手動であげたくない

Unity Cloud Build 使ってますか?
有料のサービスではありますが、億劫で時間のかかるビルドを自動化してくれるので重宝しています。アジャイルアジャイル。
特にiOSアプリを開発していてUnity Editorでは動くのに iPhoneに持っていくと動かなくなる、なんてバグを見つけてしまったときには頻繁にビルドするという状況が考えられます。
せっかくGitリポジトリにpushするだけで一発でビルドしてくれるのならばTestFlightへの配信も自動化してしまいたいですよね。

という状況にぴったりな記事を見つけました。
ありがたいです。

UnityCloudBuildでTestFlightへの配信を完全自動化する – BeXide Tech Blog

基本的にこの記事に従えばUnity Cloud BuildからTestFlightへの自動配信ができるようになると思いますが、多少のアレンジを加えたことで少しハマったので主に以下の点について書いておこうと思います。

  1. GitLabに git push
  2. GitLabのRepositoryと連携しておいたUnity Cloud Buildが自動ビルド
  3. ビルド成功通知をDiscordのチャンネルに流す
  4. Discordのbotが対象ビルドを検知してTestFlightにアップロード

元記事との変更点はBitBucketがGitLabに、SlackがDiscordに、という点ですね。
ゲームを制作している段階で、バージョン管理をGitLabで、イラストレーターさんとの連絡をDiscordで取っていた都合でそのように変更しました。

Unity Cloud Build 側ですること

GitLabあるいは他のバージョン管理ツールとUnity Cloud Buildの連携ができていることは前提とします。
なお、ここでは master ブランチにpushされたのを検知して自動でビルドしてくれる iOS_master というターゲットを利用しています。

Pre-Export Method の設定

ビルド番号の設定のため、Pre-Export Method (エクスポート前メソッド)を設定します。
上記の記事を参考にこのようにしました。

using UnityEditor;

namespace Project.Editor.CloudBuild.Editor
{
#if UNITY_CLOUD_BUILD

    public class CloudBuildHelper
    {
        public static void PreExport(UnityEngine.CloudBuild.BuildManifestObject manifest)
        {
            string buildNumber = manifest.GetValue("buildNumber", "0");
#if UNITY_IOS
           PlayerSettings.iOS.buildNumber = buildNumber;
#elif UNITY_ANDROID
           PlayerSettings.Android.bundleVersionCode = int.Parse(buildNumber);
#endif
        }
    }
    
#endif
}

名前空間(Project.Editor.CloudBuild.Editor)がやたら長いのは、.gitignoreでEditorフォルダをアップロードしないようにしているせいです。
フォルダ構成は、

Assets
┗ Project
┗ Editor
┗ CloudBuild
┗ ……

という感じにしています。
Assetsより下の階層から書いていけばUnityCloudBuildにわかってもらえるようです。

同様に、「輸出コンプライアンスの確認」チェックの回避のため、下記のようなファイルも作成しておきます。こちらは[PostProcessBuild] attributeをつけておくだけで、UnityCloudBuild上での設定は必要ありません。

using System.IO;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;

namespace Project.Editor.CloudBuild.Editor
{
    public class BuildBatch
    {
 
#if UNITY_IOS
        // TestFlight「輸出コンプライアンスの確認」
        [PostProcessBuild]
        public static void ChangeXcodePlist(BuildTarget buildTarget, string pathToBuiltProject)
        {
            if (buildTarget == BuildTarget.iOS)
            {
                var plistPath = pathToBuiltProject + "/Info.plist";
                var plist = new PlistDocument();
                plist.ReadFromString(File.ReadAllText(plistPath));
 
                var rootDict = plist.root;
                rootDict.SetString("ITSAppUsesNonExemptEncryption", "false");
 
                File.WriteAllText(plistPath, plist.WriteToString());
            }
        }
#endif
    }
}


ここまで準備できたら、次は、ビルド成功時にDiscord (もしくはSlack)に通知してくれる設定をしましょう。

左側のメニューから、 Settings > Integrations を選択し、 New Integration で新しいDiscord用 Integration の設定をします。

Discord側での認証画面に遷移するので表示の通りに Discrod Server と チャンネルを設定してください。
私は #release チャンネルに通知していくことにしました。

このようにUnityボットが通知してくれるようになります。

なお、この設定中の副作用としてwebhookの作成によって、下記記事のような問題が起こることがあるので同じ症状が出た方は見てみてください。

Discord Bot を作成する

こちらも、手動でのTestFlightアップロード設定は完了しているという前提で話を進めます。Certificateとかで少しバタバタした覚えがありますが、その辺りは他にも記事がいくつか出ていたので助けてもらいましょう……。

[AppStoreConnect] バイナリアップロードを自動化しよう! – Qiita を参考に自動化のための準備を行ってから、Botを作成していきます。

簡単なDiscord Botの作り方(初心者向け)|bami|note

こちらの記事を参考に、Discord Developer Portal で Discord Bot を作成してみました。

javascript も node.js も何もわからないのでコードは雰囲気でIDEに書いてもらいました。動いてはいます。

discord.js を読むと大体わかります。わかりにくいところはググると日本語の記事がちらほら出てきます。

なお参考にした記事とは違ってGlitchは使わず、手元で動かしているのでPCをシャットダウンしているときはもちろん動きません。寝ている間に全部終わらせたい!という方はそのような外部のツールを使うことを検討した方が良さそうです。

// Discord bot implements
const discord = require('discord.js');
const client = new discord.Client();
const token = '[1] tokenを入力してください';

// Response for Uptime Robot
const https = require('https');

https.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Discord bot is active now \n');
}).listen(3000);


// For Unity Cloud Build
const fs = require('fs').promises;
const axios = require('axios');
const {exec} = require('child_process');

client.on('message', async message => {
    if (message.author === client.user) return;

    if (message.channel.name === '[2] チャンネル名を入力してください' &&
            message.embeds.some(embed =>
                    embed.fields.find(field =>
                        field.name ==='Target').value === '[3] ターゲット名を入力してください')) {
        message.react('❤️');
        await export_app(message);
    }
});

async function get_ipa_url() {
    const api_key = "[4] ipa情報を入力してください";
    const orgid = "[4] ipa情報を入力してください";
    const projectid = "[4] ipa情報を入力してください";
    const buildtargetid = "[4] ipa情報を入力してください";

    const url = `https://build-api.cloud.unity3d.com/api/v1/orgs/${orgid}/projects/${projectid}/buildtargets/${buildtargetid}/builds?buildStatus=success&platform=ios`;

    return axios({
        url: url,
        method: 'GET',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': `Basic ${api_key}`
        },
        responseType: 'json',
    }).then(res => {
        const data = res.data;
        const json = JSON.parse(JSON.stringify(data));
        return json[0].links.download_primary.href;
    }).catch(err => {
        console.log(err.message);
    });
}

async function get_ipa(url) {
    const filepath = "build.ipa";
    const options = {
        //flag: 'w'
    };
    return axios({
        url: url,
        method: 'GET',
        responseType: 'arraybuffer',
    }).then(async res => {
        const data = res.data;
        await fs.writeFile(filepath, data, options)
            .catch(err => console.error(err));
    }).then(() => filepath);
}

async function upload_app(filepath, message) {
    const user = "[5] iOS developer情報を入力してください";
    const pass = "[5] iOS developer情報を入力してください";
    exec(`xcrun altool --upload-app --file ${filepath} --username ${user} --password ${pass}`,
        (err, stdout, stderr) => {
            if (err) {
                message.channel.send(`データ更新に失敗しました\n ${stderr}`, {split: true});
                return
            }
            message.channel.send('AppStoreのデータを更新しました');
        });
}

async function export_app(message) {
    await get_ipa_url()
        .then(url => get_ipa(url))
        .then(filepath => upload_app(filepath, message))
        .catch(err => console.error(err));
}

client.login(token);

このスクリプトをコピペして使う場合は下記の点を変更する必要があります。

[1] tokenを入力してください (4行目)

Developer Portal で作成した Bot のTokenです。 Copyをクリックするとクリップボードにコピーされます。

[2] チャンネル名を入力してください (23行目)

Discordのチャンネル名を入力します。今回の例だと’release’になります。

[3] ターゲット名を入力してください (26行目)

Unity Cloud Buildで設定したターゲットの名前を入力します。ここで絞らないと、例えばTestFlightで配信するつもりのないテスト用のビルドや、Android用のビルドの成功通知がきたときにもBotが動いてしまうので、それを阻止します。今回の例だと’iOS_master’になります。

[4] ipa情報を入力してください (33 – 36行目)

api_key: Unity DashBoard > Settings > Cloud Build > API 設定 > API キー
orgid: Unity ID > Organizations > Cloud Buildで使用している Organizationを選択 > URLの https://id.unity.com/en/organizations/ に続く部分
projectid: Unity DashBoard > Settings > General > ページ最上部、Settings の下に書いてある UPID
buildtarget: Unity DashBoard > Cloud Build > Config > 使用したいターゲットを選択して表示されている名前

[5] iOS developer情報を入力してください (74 – 75行目)

user: iOS developerに登録しているメールアドレス
pass: App 用パスワードを使う – Apple サポート を参考に作成したパスワード

最終的に

こんな感じでbotが働いてTestFlightに自動アップロードができるようになりました!