GitHub ActionsでUnityのライブラリのリリースが捗る話

QualiArtsでUnity用のライブラリ開発をしつつ、その他Unity用の開発環境の整備を行っているUnityエンジニアの @asuuma です。
この記事は[QualiArts Advent Calendar 2019](https://qiita.com/advent-calendar/2019/qualiarts)の18日目の記事です。

# TL;DR

- Gitのタグ付けをトリガーに、GitHub ActionsでReleaseを作成
- npm publishで自社のUnity Package Registryにアップロード
- unitypackageも生成してReleaseの成果物に
    - unitypackageの構造を解析して、pythonで生成することでUnityレスを実現

# GitHub Actionsとは

GitHub上で直接動作するCI/CDをサポートするためのワークフローを自動化するための機能です。
https://github.com/features/actions
2019年の11月にGAになったばかりです。
その特徴としては、以下のようなことが挙げられます。

- インターフェイスがGitHub上に完全に統合されている
- 無料枠が設定されている
- マーケットプレイスを通して、ワークフローを構築するための部品が世界中で開発されている
- 通常はGitHubが用意するVM上で実行されるが、オンプレなどに自分用の実行環境を用意して実行させることができる

## Unity開発におけるGitHub Actionsへの期待

Unity開発などのクライアントアプリの開発は、CI/CD環境を自分たちで用意する必要があります。 [^1]
特にUnityを用いたiOS/Android向けのゲームを制作する際は、自前で用意したMacマシンにJenkinsをインストールして、
CI/CD環境を構築していることが多いかと思います。これは以下の事情に依るところが大きいです。

- Unityエディタがインストールされている環境がマネージドサービスに少ないため
- iOSアプリのビルドのためにXcodeがインストールされたMacマシンが必要
- Unityアプリのビルドはハイスペックなマシンでも30-60分ほどかかる

ところがJenkins環境を維持することは簡単なことではありません。
マシンのメンテナンスなどUnityエンジニアの本来の仕事とは遠いインフラ的な業務が中心になるからです。
またJenkinsの設定や、プラグインの管理なども求められてきます。
多くの場合仮想化されていない環境での構築になるため、一度壊れると復旧するのに大変な時間を要し、その間のプロジェクトの開発は止まってしまいます。
そんな環境に身を委ねていては夜も眠れなくなってしまいます。

そこで登場したのがGitHub Actionsです。
個人的には以前から脱Jenkinsを目指していたので、GitHub標準のCI/CDはまさに待望のサービスでした。

# Unityのライブラリ開発をGitHub Actionsで自動化

## QualiArtsでのライブラリ開発について

QualiArtsではライブラリ開発が盛んに行われています。
小規模な物を含めるとざっと20ぐらいはありそうです。
そこでいつも問題となるのが、ライブラリの配布方法です。主に

1. git submoduleでの取り込み
2. unitypackageでの配布
3. 自社Unity Package Registry経由での配布

のパターンがあります。どれもメリデメがあるのでケースバイケースで使い分けています。

## ライブラリのリリースフロー

主にGitHubのRelease機能を通してリリースします。
git-flowなどの開発を通して開発していき、リリースするタイミングになったらgitのタグを打ってプッシュします。
そしてGitHubのReleaseを作成し、changelogとライブラリの成果物と共にリリースします。
また最近はUnity2019以降で開発しているプロジェクト用にUnity Package Registryも社内に立て、そこにもnpm publishで成果物をアップロードします。
リリース先が2箇所あるのと、リリースのたびにchangelogなどを整形して、成果物をビルドして、リリースするのが地味に大変でした。
絶対的な工数はそこまでかからないものの、細かい作業も多くリリースする頻度が多くなると時間を奪われ、開発モチベーションも低下してしまいます。
なのでGitHub Actionsでなるべく自動化を頑張ってみました。

## リリース作業の自動化
早速GtiHub Actuonsで上記のリリースフローを自動化していきます。

### タグが打たれたらGitHub Releaseを作る

これはGitHubの基本的な機能を利用することで簡単に実現できます。

```yaml:main.yml
name: Release

on:
  push:
    tags:
      - '*'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v1
    - name: Create Release
      id: create_release
      uses: actions/create-release@v1
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
      with:
        tag_name: ${{ github.ref }}
        release_name: ${{ github.ref }}
        draft: false
        prerelease: false
    - name: Upload Release Asset
      uses: actions/upload-release-asset@master
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      with:
        upload_url: ${{ steps.create_release.outputs.upload_url }}
        asset_path: your.unitypackage
        asset_name: your.unitypackage
        asset_content_type: application/gzip
```

ここではいったんyour.unitypackageがあるものとしてて成果物としてアップロードしています。
次のステップで、unitypackageを作ります。

## Unityエディタレスでunitypackageを生成する

通常unitypackageを作る場合、Unityエディタが必要になります。
ただしUnityエディタを利用するためには、ライセンス認証が必要だったりととても手間がかかるのと、
そもそもUnityエディタをCI/CD環境に用意しないといけません。
そこで何とかUnityエディタ無しでunitypackageを生成する方法を模索してみます。

### unitypackageの構造

unitypackage自体はtar.gz形式です。
実際に解凍してみるとわかりますが、中のファイル構造は比較的シンプルです。
ある1つのアセットをunitypackageにすると、以下のような形で圧縮されます。

```
-rw-r--r--  1 a13440 CATK\Domain Users   284  4 23  2018 ./e3cd09c8238fd4842b0c87ef7c1ee257/asset.meta
-rw-r--r--  1 a13440 CATK\Domain Users  4220 11 16  2018 ./e3cd09c8238fd4842b0c87ef7c1ee257/asset
-rw-r--r--  1 a13440 CATK\Domain Users    59 12 12 16:09 ./e3cd09c8238fd4842b0c87ef7c1ee257/pathname
```

フォルダ名はアセットのguidそのものです。
assetはアセット本体、asset.metaはそのmetaファイル、pathnameはAssets/で始まる相対パスがテキストで書かれたファイルです。
これがアセット数分繰り返されるだけなので、これを再現してあげればunitypackageとして認識されます。

### unitypackage生成するためのpythonスクリプト

```python:create_unitypackage.py
#!/usr/bin/env python3

import sys
import os
import io
import argparse
import os.path
import tarfile
import yaml
import glob

parser = argparse.ArgumentParser(description='Create unitypackage without Unity')

parser.add_argument('-r', '--recursive', action='store_true')
parser.add_argument('targets', nargs='*', help='Target directory or file to pack')
parser.add_argument('-o', '--output', required=True, help='Output unitypackage path')

args = parser.parse_args()

print('Targets:', args.targets)
print('Output unitypackage:', args.output)
print('Is recursive', args.recursive)

for target in args.targets:
    if not os.path.exists(target):
        print("Target doesn't exist: " + target)
        sys.exit(1)

def filter_tarinfo(tarinfo):
    tarinfo.uid = tarinfo.gid = 0
    tarinfo.uname = tarinfo.gname = "root"
    return tarinfo

def add_file(tar, metapath):
    filepath = metapath[0:-5]
    print(filepath)
    with open(metapath, 'r') as f:
        try:
            guid = yaml.safe_load(f)['guid']
        except yaml.YAMLError as exc:
            print(exc)
            return
 
    # dir
    tarinfo = tarfile.TarInfo(guid)
    tarinfo.type = tarfile.DIRTYPE
    tar.addfile(tarinfo=tarinfo)

    if os.path.isfile(filepath):
        tar.add(filepath, arcname=os.path.join(guid, 'asset'), filter=filter_tarinfo)
    tar.add(metapath, arcname=os.path.join(guid, 'asset.meta'), filter=filter_tarinfo)
    # path: {guid}/pathname
    # text: path of asset
    tarinfo = tarfile.TarInfo(os.path.join(guid, 'pathname'))
    tarinfo.size= len(filepath)
    tar.addfile(tarinfo=tarinfo, fileobj=io.BytesIO(filepath.encode('utf8')))

with tarfile.open(args.output, 'w:gz') as tar:
    for target in args.targets:
        add_file(tar, target + '.meta')
        if args.recursive:
            for meta in glob.glob(os.path.join(target, '*.meta')):
                add_file(tar, meta)
            for meta in glob.glob(os.path.join(target, '**/*.meta'), recursive=True):
                add_file(tar, meta)
```

pythonを使ってアセットを探し、tar.gzに固めていくスクリプトです。
引数は3種類ありますが、そんなに難しくないと思うので読めばなんとなくわかるかなと思います。
これでunitypackageをUnityを使わずに作れるようになりました。

## unitypackageのビルドとアップロード

先程のmain.ymlを少し改造して、unitypackageのビルドも同時にやるようにしたのが以下です。

```yaml:main.yml
name: Release

on:
  push:
    tags:
      - '*'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v1
    - name: Set up python
      uses: actions/setup-python@master
      with:
        python-version: '3.x'
    - uses: actions/cache@v1
      if: startsWith(runner.os, 'Linux')
      with:
        path: ~/.cache/pip
        key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
        restore-keys: |
          ${{ runner.os }}-pip-
    - name : Pip install
      run: pip install PyYAML
    - name: Create unitypackage
      run: python3 create_unitypackage.py -r -o you.unitypackage Assets/Path/To/Library
    - uses: actions/upload-artifact@v1
      with:
        name: unitypackages
        path: your.unitypackage
  github_release:
    needs: [build]
    runs-on: [ubuntu-latest]
    steps:
    - uses: actions/download-artifact@master
      with:
        name: unitypackages
    - name: Create Release
      id: create_release
      uses: actions/create-release@v1
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
      with:
        tag_name: ${{ github.ref }}
        release_name: ${{ github.ref }}
        draft: false
        prerelease: false
    - name: Upload Release Asset
      uses: actions/upload-release-asset@master
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      with:
        upload_url: ${{ steps.create_release.outputs.upload_url }}
        asset_path: unitypackages/your.unitypackage
        asset_name: your.unitypackage
        asset_content_type: application/gzip
```

具体的に増えたstepは以下です。

- python3環境のセットアップ
- pipのインストールと高速化のためのキャッシュ設定
- unitypackageのビルド
- GitHub Actionsのartifact機能を利用した成果物のアップロード・ダウンロード

## 自社Unity Package Registryへのアップロード

最後にUnity Package Registryへのアップロード部分のstep抜粋です。

```yaml:main.yml
  deploy:
    needs: [build]
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v1
    - name: Set up Node.js
      uses: actions/setup-node@master
      with:
        node-version: '12.x'
        registry-url: 'http://your.registry.example.com'
    - name: Copy README.md
      run: cp README.md Assets/Path/To/Library
    - name: Publish if version has been updated
      run: npm publish Assets/Path/To/Library
      env:
        NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} # You need to set this in your repo settings
```

ポイントは、npmの認証用のトークンを事前にsecretsの中に設定しておくのと、
もしレジストリをIP制限などかけている場合はGitHub Actionsのソースレンジを許可しておく必要があります。
ただし現状のGitHub Actionsのソースレンジは[ドキュメント](https://help.github.com/ja/actions/automating-your-workflow-with-github-actions/virtual-environments-for-github-hosted-runners)記載の通り、
AzureのEast US 2リージョン丸ごとであり、かつ随時変わる可能性があるためIP制限はおすすめできません。
どうしてもIP制限が必要な場合は、セルフホステッドランナーを利用することをオススメします。

## GitHub Actionsで自動化を実現出来なかったこと

実は更に自動化することも検討していました。
例えば各種バージョン表記の更新、CHANGELOG.mdの自動生成などです。
前者はスクリプトまでは書いたのですが、 GitHub Actionsの何をトリガーにそれを実行するかを決められずに手動で実行することになりました。
後者を実現するGitHub Actionsのstepがマーケットプレイスにもあるのですが、commitメッセージをルール通りに書く必要があるなど、導入ハードルが高いため断念しました。

# まとめ

Jenkinsの利用を段階的に減らしていくために、GitHub Actions化をいろいろと頑張りました。
まずはライブラリ開発の地味に大変だったところを自動化しました。
今後は実際のゲームのビルドなど、頻繁に走るビルドをGitHub Actionsのセルフホステッドランナーに寄せられないか検証を進めていいきたいと思います。


[^1]: 大手のクラウドサービスにはマネージドなCI/CDサービスがあるため、サーバー開発ではそれを利用することが多い。またUnity社もクラウドビルドサービスを提供しているが、大規模開発では使いづらい面がある。