Jenkins+PythonでUnityの自動ビルド&自動デプロイ
「神戸電子専門学校 ゲーム技研部 Advent Calendar 2023」7日目の記事です。
はじめに
みなさんは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.yaml
、secrets/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)
まずビルドプロセスです。おおまかな処理の流れとしては、
-
os.getenv()
で環境変数から引数を取得 - コマンドの作成
- 前回のビルドデータがあれば削除後、ビルドフォルダを作成
- ビルドを実行
- 不要なデータを削除
- 結果を返す
となっています。今回、Unityへ渡す引数のやり取りは環境変数に設定して渡しているので、os.getenv()
で取得しています。ビルドが終了すると、ビルドディレクトリに含まれているButDontShipItWithYourGame
やDoNotShip
を自動的に削除するようにしています。実行ファイルには不要なうえ、容量もかなりあるので今回は問答無用で削除するようにしています。また、単体でも実行できるように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にアップロードしています。おおまかな処理の流れとしては、
- パスなどの取得、pydrive2の初期化
- ビルドフォルダをzip圧縮
- GoogleDriveにzipとunityのビルドログをアップロード
- 直リンクを返す
のようになっています。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 Strategy
をManually provided keys
に変更します。
こちらのサイトにある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