🎮

Jenkins+PythonでUnityの自動ビルド&自動デプロイ

2023/12/07に公開

「神戸電子専門学校 ゲーム技研部 Advent Calendar 2023」7日目の記事です。

https://qiita.com/advent-calendar/2023/kdgamegiken

はじめに

みなさんはUnityで開発している際に、頻繁にビルドをしているでしょうか?おそらく業務以外ではあまりしていないんじゃないかと思います。そしてアルファ/ベータテスト用に久々にビルドしてみるとビルドエラー/実行時エラーが出て修正作業に、、、なんてことの経験もあるのではないでしょうか。私はあります。(そしてビルドエラーの大半の原因はUnityEditorのusingだったり↓)

私のチーム制作ではこういった潜在的なエラーを無くすためや、その他の事情も重なって「自動ビルド&自動デプロイ」を組むことにしました。この記事ではどういったことに取り組んだかを紹介していきたいと思います。

その他の事情

現在、私はUnityでNetcode for GameObjectsを利用したマルチプレイヤーゲームを数名のチームで開発しています。マルチプレイヤーゲームでは、正しく全員に同期できているかなどの複数インスタンスでのデバッグがメインになってくるのですが、Unityの標準機能では同時にプレイできるデバッグ機能が存在しないため、ParrelSyncのようなエディターを複製できる外部Packageを導入するか、ビルドしてアプリケーションを複数起動するかしかありませんでした。私たちの開発では、基本的なアクションやスポーンなどの同期のデバッグはParrelSyncで行い、同時アクション時の挙動など同時入力が必要(=人数が必要)な場合のデバッグはビルドを使っています。今までは確認してほしい内容があるたびに個人でビルドを行い、共有ドライブにアップロードして共有しており、そのイテレーションだけでも10分以上はかかっていたので、今回自動ビルド&自動デプロイを組むことにしました。

環境

  • Windows 10 Home
  • Jenkins v2.426.1
  • Python 3.9.11
  • Unity 2022.3.12f1

Windowsで環境構築をした経緯

初めはDocker内で完結しようと思っていたのですが、GUI無しでのUnityのインストール方法が不明だったため断念しました。
またGameCIにUnityのDocker ContainerもあったのですがWindows ContainerだったためHome Editionでは非対応なためこれも断念。
GitHub Actionsに関しては、プライベートリポジトリでの無料枠では後で説明する今回の仕様には満たなかったのでこれも断念。
→最終的にWindows 10 Home上に直接環境構築することにしました。

仕様

  • コミット毎のビルド
    git pullを行い、UnityのLibraryフォルダを保持してあるworkspaceを利用して行うビルド
    差分のみビルドしているようなものなので、ビルド時間は短い
  • 1週間毎のビルド
    workspaceを削除し、git cloneを行い、UnityのLibraryフォルダ再構築から行うビルド
    プロジェクトが大きくなるにつれ、ビルド時間は伸びる。このビルドが終わったタイミングで、コミット毎のビルドのworkspaceも一度削除する。

また、共通して

  • ビルドデータをzipで圧縮し、ログデータと一緒にGoogleDriveにアップロード
  • ビルド開始/ビルドの成否/GoogleDriveへの直リンクをDiscordにWebhookで通知

と、ビルドプロセス以外のことも行っています。

コミット毎にビルドするようにした経緯

タグ付けなどでビルド処理を発行するようにしようかなとも考えたのですが、頻繁にデバッグする可能性があるうえに、それぞれのメンバーにまた新しく操作方法などを教えたり、作業中に操作させるのもコストだと思ったので、今回はコミット毎にビルドしすぐにビルドデータを共有できるようにしました。

環境構築

新しいPCに環境構築していくので、インストールから始めます。それぞれのインストール手順の説明は省きますが、ちょっとした注意点も紹介します。

  • Unity
    UnityHubで必ずLicense認証する必要があります。またIL2CPPでのビルドの場合はIL2CPPモジュールとVisualStudioが必須です。
  • Python
    GoogleDriveにアップロードするにはライブラリpydrive2が必要です。また、Webhookに関してはRequestsが必要です。(pydrive2の依存としてrequestsもインストールされます。)
    pip install pydrive2
    pip install requests
    また、私の環境だけかもしれませんがモジュールのパスが正しくなかったせいかJenkinsから認識できなかった場合がありました。これの対処法は環境構築のJenkinsで説明します。
  • Jenkins
  • Git

Unity

Unityのプロジェクトをヘッドレスで操作するには、コマンドライン引数を利用します。ただ、Unityのコマンドライン引数に直接ビルドするという項目はないですが、実行する関数を指定することはできます。つまりビルドの処理はスクリプトに書いていくということです。
説明を挟むため、いくつかに分割して説明していきます。

まず、Assets直下にEditorというフォルダを作ります。そして、CI.csというファイルを作成します。ソースコードは1から作るのでとりあえず全部消します。

using System;
using UnityEditor;
using UnityEditor.AddressableAssets.Settings;
using UnityEditor.Build.Reporting;
using UnityEngine;

usingです。UnityEditor.AddressableAssets.Settingsに関しては、Addressablesを利用していないプロジェクトなら必要ないです。

public static class CommandLineArgs
{
  public static string GetValue(string name)
  {
    var args = Environment.GetCommandLineArgs();
    for (int i = 0; i < args.Length; i++)
    {
      if (args[i] == $"-{name}" && args.Length > i + 1)
      {
        return args[i + 1];
      }
    }
    return null;
  }
}

コマンドライン引数からオプションを検索し、その引数を取得する関数です。
これは例ですが、
Unity.exe -productName TestProject
といった引数を渡した場合、
GetValue(productName)と指定すると、-productNameを検索しTestProjectを文字列として返す
という処理になっています

public class CI
{
  [MenuItem("CI/Build")]
  public static void Build()
  {
    // ここにビルドの処理を書く
  }
}

次はビルド用のクラスと静的関数を作ります。関数の上にある属性は、UnityのMenu欄にボタンとして配置する属性です。エディター内でワンクリックでビルドできるように配置しています。今後の処理はコメントの部分に書きます。

// Command line arguments
const string BUILD_PATH               = "buildPath";
const string BUILD_TARGET             = "buildTarget";
const string BUILD_TARGET_GROUP       = "buildTargetGroup";
const string BUILD_VERSION            = "buildVersion";
const string ENABLE_DEVELOPMENT_BUILD = "enableDevelopmentBuild";
const string ENABLE_IL2CPP            = "enableIL2CPP";
const string PRODUCT_NAME             = "productName";

const string CLEARED_LIBRARY_FOLDER   = "clearedLibraryFolder";

// Get command line arguments
var buildTarget
  = (BuildTarget)Enum.Parse(typeof(BuildTarget), CommandLineArgs.GetValue(BUILD_TARGET)
  ?? BuildTarget.StandaloneWindows64.ToString());
var buildTargetGroup
  = (BuildTargetGroup)Enum.Parse(typeof(BuildTargetGroup), CommandLineArgs.GetValue(BUILD_TARGET_GROUP)
  ?? BuildTargetGroup.Standalone.ToString());
var buildVersion
  = CommandLineArgs.GetValue(BUILD_VERSION)
  ?? Bootstrap.VERSION;
var enableDevelopmentBuild
  = string.IsNullOrEmpty(CommandLineArgs.GetValue(ENABLE_DEVELOPMENT_BUILD)) == false;
var enableIL2CPP
  = string.IsNullOrEmpty(CommandLineArgs.GetValue(ENABLE_IL2CPP)) == false;
var productName
  = CommandLineArgs.GetValue(PRODUCT_NAME)
  ?? Application.productName;
var buildPath
  = CommandLineArgs.GetValue(BUILD_PATH)
  ?? $"build/{productName}.exe";

var clearedLibraryFolder
  = string.IsNullOrEmpty(CommandLineArgs.GetValue(CLEARED_LIBRARY_FOLDER)) == false;

引数をパースする処理です。存在しなかった場合は何かしらのデフォルトの値をセットするようにしています。引数の説明を軽くすると

  • buildPath
    実行アプリケーションの保存先。拡張子まで指定する必要がある。
  • buildTarget
    指定されたプラットフォームの中でも細かな指定ができる。例えばStandaloneの中でもWindows用、Linux用、MacOS用等々。
  • buildTargetGroup
    プラットフォームを指定する。StandaloneとかAndroidとか。
  • buildVersion
    ビルドのバージョンを指定する。リリースでないなら特に関係無いかな?
  • enableDevelopmentBuild
    DevelopmentBuildを有効化するかどうか。有効化するとビルドでもログとかプロファイルとか見れる。
  • enableIL2CPP
    IL2CPPを有効化するかどうか。有効化すると内部でC#コードがC++コードに変換され高速化が見込まれる。
  • productName
    アプリケーション名。この名前がタイトルバーの名前になる。
  • clearedLibraryFolder
    UnityのLibraryフォルダをクリアした後に、バッチモードでビルドすると初回のみエラーがでることがあるので、もう一度ビルドするかどうか。

です。プロジェクトによって必要な引数があれば、ここに追記するだけで増やすことができます。

PlayerSettings.bundleVersion = buildVersion;
PlayerSettings.productName = productName;
EditorUserBuildSettings.development = enableDevelopmentBuild;
BuildOptions buildOptions = BuildOptions.None;
if (enableDevelopmentBuild)
{
  // Development Build
  buildOptions |= BuildOptions.Development;
}
if (enableIL2CPP)
{
  // IL2CPP
  PlayerSettings.SetScriptingBackend(buildTargetGroup, ScriptingImplementation.IL2CPP);
}

// build addressable assets
AddressableAssetSettings.BuildPlayerContent();

ビルド設定をセットしている処理です。最後にAddressableAssetのビルドも行っています。これもプロジェクトが利用していなければ不要です。

BuildReport InternalBuild()
{
  // Build
  return BuildPipeline.BuildPlayer(new BuildPlayerOptions
  {
    scenes = new[]
    {
      "Assets/Scenes/Bootstrap.unity",
      "Assets/Scenes/Title.unity",
      "Assets/Scenes/Game.unity",
      "Assets/Scenes/Result.unity",
    },
    locationPathName = buildPath,
    targetGroup = buildTargetGroup,
    target = buildTarget,
    options = buildOptions,
  });
}

var buildReport = InternalBuild();

// In Unity, batch mode builds may fail after Library deletion, so build again without causing an error.
if (buildReport.summary.result == BuildResult.Failed
  && clearedLibraryFolder)
{
  buildReport = InternalBuild();
}

実際にビルドを呼び出している処理です。オプションによって2回呼び出すことがあるので、内部関数にしています。

switch (buildReport.summary.result)
{
  case BuildResult.Succeeded:
    Debug.Log("Build succeeded.");
    EditorApplication.Exit(0);
    break;
  case BuildResult.Failed:
    Debug.LogError("Build failed.");
    break;
  case BuildResult.Cancelled:
    Debug.LogError("Build canceled.");
    break;
  default:
    break;
}

EditorApplication.Exit(1);

ビルド結果を受け取る処理です。成功すれば0を、それ以外の場合は1を返すようにしています。
これでUnity側の対応は終わりです。

Python

次に一連のビルドプロセスと通知をするためのPythonを書きます。今回は1クラスに1ファイルという分け方をしています。

まず、スクリプトの前に設定ファイルを作成します。必要な設定ファイルは、logging用のlog_config.jsonと、pydrive2用のsettings.yamlsecrets/client_secrets.json secrets/credentials.jsonです。pydrive2の設定に関しては今回割愛します。

{
  "version": 1,
  "disable_existing_loggers": false,
  "formatters": {
    "default": {
      "format": "[%(asctime)s %(levelname)-8s] %(name)-15s - %(message)s"
    }
  },
  "handlers": {
    "console": {
      "class": "logging.StreamHandler",
      "formatter": "default",
      "stream": "ext://sys.stdout",
      "level": "DEBUG"
    }
  },
  "loggers": {
    "": {
      "handlers": ["console"],
      "level": "DEBUG"
    }
  }
}

logging用の設定ファイルです。これをスクリプトファイルと同じディレクトリに配置してください。

# standard
import json
import os
import shutil
import subprocess
from datetime import datetime
from logging import config, Logger, getLogger


class build:

  def __init__(self):
    raise NotImplementedError

  @staticmethod
  def run(logger: Logger) -> dict:
    # get environment variables

    # required
    unity_path   = os.getenv('UNITY_PATH')
    build_method = os.getenv('BUILD_METHOD')
    log_file     = os.getenv('LOG_FILE')
    project_path = os.getenv('PROJECT_PATH')

    cmd = f'{unity_path} -batchmode -quit -projectPath {project_path} -logFile {log_file} -executeMethod {build_method}'

    # optional
    build_path               = os.getenv('BUILD_PATH')
    build_target             = os.getenv('BUILD_TARGET')
    build_target_group       = os.getenv('BUILD_TARGET_GROUP')
    build_version            = os.getenv('BUILD_VERSION')
    enable_development_build = os.getenv('ENABLE_DEVELOPMENT_BUILD') is not None
    enable_il2cpp            = os.getenv('ENABLE_IL2CPP') is not None
    product_name             = os.getenv('PRODUCT_NAME')
    cleared_library_folder   = os.getenv('CLEARED_LIBRARY_FOLDER') is not None
    force_reimport_assets    = os.getenv('FORCE_REIMPORT_ASSETS') is not None

    if build_path:
      cmd += f' -buildPath {build_path}'
    if build_target:
      cmd += f' -buildTarget {build_target}'
    if build_target_group:
      cmd += f' -buildTargetGroup {build_target_group}'
    if build_version:
      cmd += f' -buildVersion {build_version}'
    if enable_development_build:
      cmd += f' -enableDevelopmentBuild'
    if enable_il2cpp:
      cmd += f' -enableIL2CPP'
    if product_name:
      cmd += f' -productName {product_name}'
    if cleared_library_folder:
      cmd += f' -clearedLibraryFolder'
    if force_reimport_assets:
      cmd += f' -forceReimportAssets'

    # remove double quotes
    project_path = project_path.replace('"', '')
    build_path = build_path.replace('"', '')

    # change directory
    os.chdir(project_path)

    # delete before build data
    build_dir = os.path.dirname(build_path)
    if os.path.exists(build_dir):
      logger.info('Previous build data exists.')
      logger.info(f'Delete : {build_dir}')
      shutil.rmtree(build_dir)
      logger.info(f'Complete delete : {build_dir}')

    # create build directory
    logger.info('Create build directory.')
    logger.info(f'Create : {build_dir}')
    os.makedirs(build_dir)
    logger.info(f'Complete create : {build_dir}')

    # execute command
    logger.info('Start build.')
    logger.info(f'Full command. : {cmd}')
    build_datetime = datetime.now()
    completed_process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
    elapsed_build_seconds = (datetime.now() - build_datetime).total_seconds()
    logger.info(f'Elapsed build sec. : {elapsed_build_seconds}')
    logger.info(f'Complete build. : {completed_process.returncode}')

    if completed_process.returncode == 0:
      # remove dont ship folder
      unwanted_dir_names = ["ButDontShipItWithYourGame", "DoNotShip"]
      for dirpath, dirnames, filenames in os.walk(build_dir, topdown=False):
        for dirname in dirnames:
          for unwanted_dir_name in unwanted_dir_names:
            if unwanted_dir_name in dirname:
              dir_to_delete = os.path.join(dirpath, dirname)
              logger.info('Unwanted directory exists.')
              logger.info(f'Delete : {dir_to_delete}')
              shutil.rmtree(dir_to_delete)
              logger.info(f'Complete delete : {dir_to_delete}')
      # copy steam_appid.txt
      logger.info('Copy steam_appid.txt')
      shutil.copyfile(f'{project_path}\steam_appid.txt', f'{build_dir}\steam_appid.txt')
      logger.info('Complete copy steam_appid.txt')
    else:
      logger.error(completed_process.stderr.decode('cp932'))

    return {
      'ELAPSED_BUILD_SECONDS': elapsed_build_seconds,
      'FULL_COMMAND': cmd,
      'RETURN_CODE': completed_process.returncode,
      'STDOUT': completed_process.stdout.decode('cp932'),
      'STDERR': completed_process.stderr.decode('cp932'),
    }


if __name__ == "__main__":
  with open(f'{os.path.dirname(os.path.abspath(__file__))}/log_config.json', 'r') as f:
    config.dictConfig(json.load(f))
  logger = getLogger(__name__)
  build.run(logger)

まずビルドプロセスです。おおまかな処理の流れとしては、

  1. os.getenv()で環境変数から引数を取得
  2. コマンドの作成
  3. 前回のビルドデータがあれば削除後、ビルドフォルダを作成
  4. ビルドを実行
  5. 不要なデータを削除
  6. 結果を返す

となっています。今回、Unityへ渡す引数のやり取りは環境変数に設定して渡しているので、os.getenv()で取得しています。ビルドが終了すると、ビルドディレクトリに含まれているButDontShipItWithYourGameDoNotShipを自動的に削除するようにしています。実行ファイルには不要なうえ、容量もかなりあるので今回は問答無用で削除するようにしています。また、単体でも実行できるようにif __name__ == "__main__":に実行できる処理も書いています。

# standard
import os
import shutil
from datetime import datetime
from logging import Logger

# pydrive
from pydrive2.auth import GoogleAuth
from pydrive2.drive import GoogleDrive


class google_drive_sender:

  def __init__(self):
    raise NotImplementedError

  @staticmethod
  def run(logger: Logger) -> dict:
    PARENTS_ID = 'PLEASE_REPLACE_WITH_YOUR_OWN_FOLDER_ID'
    BUILD_DIR = f'{os.path.dirname(os.environ["BUILD_PATH"])}'.replace('"', '')
    TMP_ZIP_PATH = fr'{BUILD_DIR}\..\build.zip'

    # initialize
    os.chdir(os.path.dirname(os.path.abspath(__file__)))
    logger.info('PyDrive initialize.')
    gauth = GoogleAuth()
    gauth.LocalWebserverAuth()
    drive = GoogleDrive(gauth)
    logger.info('PyDrive initialize finished.')

    # zip the build folder
    logger.info('Zip the build folder.')
    shutil.make_archive(base_name=os.path.splitext(TMP_ZIP_PATH)[0], format='zip', root_dir=BUILD_DIR)
    logger.info('Zip the build folder finished.')

    # make build folder on google drive
    logger.info('Make build folder on google drive.')
    folder = drive.CreateFile(
      {
        'title': f'{os.environ["PRODUCT_NAME"]}-{datetime.now().strftime("%Y%m%d%H%M%S")}-{os.environ["GIT_COMMIT"]}',
        'mimeType': 'application/vnd.google-apps.folder',
        'parents': [{'id': PARENTS_ID}]
      })
    folder.Upload()
    
    # upload to google drive
    logger.info('Upload to google drive.')
    zip_file = drive.CreateFile(
      {
        'title': f'{os.environ["PRODUCT_NAME"]}-{datetime.now().strftime("%Y%m%d%H%M%S")}-{os.environ["GIT_COMMIT"]}.zip',
        'mimeType': 'application/zip',
        'parents': [{'id': folder['id']}]
      })
    zip_file.SetContentFile(TMP_ZIP_PATH)
    zip_file.Upload()

    # upload unity build log file to google drive
    logger.info('Upload log file to google drive.')
    unity_log_file = drive.CreateFile(
      {
        'title': f'unity.log',
        'mimeType': 'text/plain',
        'parents': [{'id': folder['id']}]
      })
    unity_log_file.SetContentFile(os.environ['LOG_FILE'].replace('"', ''))
    unity_log_file.Upload()

    logger.info('Upload to google drive finished.')

    # return alternate link
    return {
      'LINK': folder['alternateLink'],
      'RETURN_CODE': 0,
      'UNITY_LOG_LINK': unity_log_file['alternateLink'],
      'ZIP_LINK': zip_file['alternateLink'],
    }

pydrive2を使ってGoogleDriveにアップロードしています。おおまかな処理の流れとしては、

  1. パスなどの取得、pydrive2の初期化
  2. ビルドフォルダをzip圧縮
  3. GoogleDriveにzipとunityのビルドログをアップロード
  4. 直リンクを返す

のようになっています。AlternateLinkというのは、そのファイル/フォルダを共有するSharedLinkではないので、万が一外部にリンクが漏れてもそのドライブにアクセスできる権限がないと閲覧自体不可能なリンクになっています。名前のフォーマットは年月日時分秒-コミットハッシュにしています。日付フォーマットを先頭に持ってくるほうが、ソートしやすいためこうしています。

↓こんな感じにアップロードされます。

# standard
import json
import os
from logging import Logger

# requests
import requests


class notification:

  def __init__(self):
    raise NotImplementedError

  @staticmethod
  def run(logger: Logger, build_result: dict, googledrivesender_result: dict) -> dict:
    DISCORD_WEBHOOK_URL = 'PLEASE_REPLACE_IT_WITH_YOUR_OWN_DISCORD_WEBHOOK_URL'
    
    logger.info('Start notification.')
    data = {
      'username': 'Build Notification by Jenkins ojisan',
      "embeds": [
        {
          'author': {
            'name': f'Build : {build_result["RETURN_CODE"] == 0 and "succeeded" or "failed"}',
          },
          'title': '**>>>Shared link<<<**',
          'description': f'{os.environ["JOB_NAME"]} {os.environ["BUILD_NUMBER"]}',
          'url': googledrivesender_result.get('LINK'),
          'color': build_result['RETURN_CODE'] == 0 and 0x00ff00 or 0xff0000,
          'fields': [
            {
              'name': 'Summary',
              'value': (
                f'- RETURN_CODE : {build_result["RETURN_CODE"]}\n'
                f'- ELAPSED_BUILD_SECONDS : {build_result["ELAPSED_BUILD_SECONDS"]}s\n'
                f'- FULL_COMMAND : {build_result["FULL_COMMAND"]}\n'
                ),
            }
          ]
        }
      ]
    }
    headers = {'Content-Type': 'application/json'}
    requests.post(DISCORD_WEBHOOK_URL, data=json.dumps(data), headers=headers)
    
    logger.info('Notification finished.')

    return {
      'RETURN_CODE': 0,
    }

requestsを使い、webhook経由でdiscordに通知しています。

↓こんな感じに通知されます。

ビルド成功時
ユーザー名がJenkinsの通知は後程説明するDiscord Notifierの機能です。

ビルド失敗時
ユーザー名がJenkinsの通知は後程説明するDiscord Notifierの機能です。

# standard
import json
import os
import sys
from logging import config, getLogger

# my module
from build import build
from googledrivesender import google_drive_sender
from notification import notification


def main():
  with open(f'{os.path.dirname(os.path.abspath(__file__))}/log_config.json', 'r') as f:
    config.dictConfig(json.load(f))
  logger = getLogger(__name__)

  os.environ['UNITY_PATH'] = r'"C:\Program Files\Unity\Hub\Editor\2022.3.12f1\Editor\Unity.exe"'
  os.environ['PROJECT_PATH'] = fr'"{os.getenv("WORKSPACE")}"'
  os.environ['LOG_FILE'] = fr'"{os.getenv("WORKSPACE")}\build\build.log"'
  os.environ['BUILD_METHOD'] = 'CI.Build'

  os.environ['BUILD_PATH'] = fr'"{os.getenv("WORKSPACE")}\build\windows\ProductName.exe"'
  os.environ['BUILD_TARGET'] = 'StandaloneWindows64'
  os.environ['BUILD_TARGET_GROUP'] = 'Standalone'
  os.environ['BUILD_VERSION'] = '0.0.1a'
  #os.environ['ENABLE_DEVELOPMENT_BUILD'] = ''
  os.environ['ENABLE_IL2CPP'] = ''
  os.environ['PRODUCT_NAME'] = 'ProductName'

  if os.path.isdir(fr'{os.getenv("WORKSPACE")}\library') is False:
    os.environ['CLEARED_LIBRARY_FOLDER'] = ''
  #os.environ['FORCE_REIMPORT_ASSETS'] = ''

  build_result = None
  googledrivesender_result = None
  notification_result = None

  logger.info("Start build process.")
  build_result = build.run(logger)
  logger.info("Build process finished.")
  if build_result['RETURN_CODE'] != 0:
    logger.error("Build process failed.")
    
  logger.info("Start google drive upload process.")
  googledrivesender_result = google_drive_sender.run(logger)
  logger.info("Google drive upload process finished.")
  if googledrivesender_result['RETURN_CODE'] != 0:
    logger.error("Google drive upload process failed.")

  logger.info("Start notification process.")
  notification_result = notification.run(logger, build_result, googledrivesender_result)
  logger.info("Notification process finished.")
  if notification_result['RETURN_CODE'] != 0:
    logger.error("Notification process failed.")

  if (build_result['RETURN_CODE'] != 0
    or googledrivesender_result['RETURN_CODE'] != 0
    or notification_result['RETURN_CODE'] != 0):
    logger.error("Some process failed.")
    sys.exit(1)
  else:
    logger.info("All process finished.")
    sys.exit(0)


if __name__ == '__main__':
  main()

Jenkinsから実行するスクリプトです。それぞれの関数を実行して戻り値を受け取ったり引数として渡したりしています。最終的にビルド結果のRETURN_CODEを元にsys.exitでJenkisn側に実行の成否を送信しています。この作成したスクリプトはビルドするプロジェクトのTools/に配置します。これでPythonスクリプトは終わりです。

Jenkins

Jenkinsの初期セットアップの説明は割愛します。(ID/Passwordを設定するだけです。)
Windows環境において必要なセットアップを説明します。

shコマンド用の設定

まず、shコマンド実行用の実行ファイルを指定します。ダッシュボードJenkinsの管理System Configuration/Systemの中にあるシェル/シェル実行ファイルという項目に移動します。Git for Windowsをインストールしているなら、デフォルトで以下のパスにあると思います。
C:\Program Files\Git\bin\sh.exe

SSH接続用の設定

次に、SSH接続用のHost key(FingerPrint)を設定します。ダッシュボードJenkinsの管理Security/Securityの一番下にあるGit Host Key Verification Configurationに移動します。
Host Key Verification StrategyManually provided keysに変更します。

https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints

こちらのサイトにあるHost KeyをApproved Host Keysに設定します。

次に認証情報を設定します。フリースタイル・プロジェクトのビルドで何か一つ適当なジョブを作成してください。
作成したら設定ソースコード管理へ移動しGitを選択します。
リポジトリ/リポジトリURLにはSSH接続用のGitURLを貼り付けてください。すると赤文字でエラーが出ると思います。
次に認証情報の下にある+追加をクリックし、Jenkinsをクリックします。すると認証情報を設定できる画面が出てくるので、種類をSSHユーザー名と秘密鍵に変更し、ユーザー名には公開鍵の末尾にある名前を挿入し、秘密鍵直接入力にチェックを入れAddをクリックしその中に秘密鍵を挿入します。

認証情報の追加が終わったら、認証情報の欄を自分が先ほど追加したユーザー名となっている項目を選択してください。しばらくするとエラーが消えるはずです。

Python用の設定

これは環境によっては設定しなくてもよい項目です。

先ほどの適当に作ったジョブをそのまま使います。同じく設定画面にてBuild Stepsへ移動します。ビルド手順の追加からシェルの実行をクリックし追加します。

py -m pip install pydrive2
py -m pip install requests

シェルスクリプトの欄にこの2行を追加します。そして保存をクリックし、ビルド実行をクリックしてください。これでモジュールがインストールされ使えるようになります。

プラグインの導入

最後にプラグインを導入します。今回はDiscord Notifierを導入しました。Pythonスクリプト内でもWebhookを使った通知は実装しましたが、これはJenkins内のログや開始などを簡単に通知してくれます。

これでセットアップは終わりです。作成したジョブはもう不要なので削除してください。次に本番環境用のジョブを作成していきます。

ジョブの作成

Jenkinsは一つのフロー単位をジョブといいます。今回は1week用のジョブとcommit用のジョブを作成しました。といってもジョブの内容はほとんど共通です。

ソースコード管理Gitを選択しリポジトリ/リポジトリURLリポジトリ/認証情報を設定して赤文字が表示されないようにします。クローンのタイムアウトが10分と設定されているので、リポジトリの容量が大きい場合はビルドエラーとなってしまう可能性があります。タイムアウトを延長したい場合は、追加処理の中のAdvanced clone behavioursを選択し、クローンとフェッチのタイムアウト(分)に数字を入力します。私の場合は30と入力しています。

ビルド・トリガSCMをポーリングを選択し、スケジュールには* * * * *を入力します。(それぞれの間に空白が必要です)
これでGitHubの変更を毎分ポーリングし、変更があれば次のステップへ進むようになります。
(1週間の定期的実行場合は、定期的に実行を選択し、0 0 * * 0を入力します。)

Build Stepsシェルの実行を選択し、py $WORKSPACE/Tools/ci-cd/jenkins-ci-cd.pyと入力します。このパスは環境によって違うと思うので、適宜変更してください。私はプロジェクト自体にTools/ci-cdというフォルダを作成し、その中にスクリプトを配置しています。

ビルド後の処理Discord Notifierを選択し、Webhook URLにDiscordに通知するWebhook URLを貼り付けます。開始したときの通知と、失敗したときのみログファイルとコミット(コメント付き)を通知したいので、高度な設定の中のSend only failedにチェックを入れ、Show changeset in embed Send log file to discord Send notification when job startsにチェックを入れました。
また、1weekのほうでは追加でビルド終了後にワークスペースを削除するを実行するようにしています。

これで、コミット毎にJenkinsのほうで検知し、ビルドプロセスを実行し、GoogleDriveにアップロード&Discordに通知するというフローが自動できるようになりました。

おわりに

ビルドエンジニアになりたいわけではないので、CI/CDまではやらずとりあえずビルド&デプロイまでの自動化のみ取り組みました。やってみた感想としては、チーム開発のリリースも近づいており実践的なネットワークデバッグが必要になってくる中で自動デプロイまでできたのはかなり効率化できていると感じました。あとPython触ってて本当によかったなと思いました。

神戸電子専門学校ゲーム技術研究部

Discussion