Jenkins CI
Unity Project Build
1. Jenkins Job 구성
Pre Step (락파일 정리 및 Unity 프로세스 종료)
set -euo pipefail
pkill -f "/Contents/MacOS/Unity" || true
rm -f "$WORKSPACE/Temp/UnityLockfile" || true
rm -f "$WORKSPACE/Library/EditorInstance.json" || true
Unity 가 열려 있으면 배치모드 실행이 막힌다. (로그에 “Another Unity instance is running…” 표시) → 필수 전처리.
Invoke Unity 3D Editor
-batchmode
-quit
-nographics
-logFile "$WORKSPACE/Editor.log"
-projectPath "$WORKSPACE"
-executeMethod Project.EditorTools.BuildProcess.CI
-buildKind Both
-development false
-clean true
-out "Builds"
-branch "TestPlay"
- -executeMethod Project.EditorTools.BuildProcess.CI 👉 namespace Project.EditorTools 안의 정적 메서드 CI() 를 실행한다. (namespace Editor 는 Unity 내부 UnityEditor.Editor 와 충돌하므로 변경 필수)
2. Build Process
경로: Assets/Editor/BuildProcess.cs
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.Build.Reporting;
using UnityEngine;
namespace Project.EditorTools
{
public static class BuildProcess
{
private static string Branch = "TestPlay";
private static string ProductName = Application.productName;
private static string Version = PlayerSettings.bundleVersion;
private static string OutputRoot = "Builds";
private static bool Development = false;
private static bool CleanOutput = true;
private static string WinExeName = "Editor";
private static string MacAppName = "Editor";
public enum BuildKind { Mac, Windows, Both }
public static void CI()
{
string[] args = Environment.GetCommandLineArgs();
BuildKind buildKind = ParseEnum(GetArg(args, key: "-buildKind"), def: BuildKind.Both);
ProductName = GetArg(args, key: "-productName", defaultValue: ProductName);
Version = GetArg(args, key: "-version", defaultValue: Version);
OutputRoot = GetArg(args, key: "-out", defaultValue: OutputRoot);
Development = ParseBool(GetArg(args, key: "-development"), def: Development);
CleanOutput = ParseBool(GetArg(args, key: "-clean"), def: CleanOutput);
WinExeName = GetArg(args, "-winExe", defaultValue: WinExeName);
MacAppName = GetArg(args, "-macApp", defaultValue: MacAppName);
Branch = GetArg(args, "-branch", defaultValue: Branch);
if (!string.IsNullOrEmpty(ProductName)) PlayerSettings.productName = ProductName;
if (!string.IsNullOrEmpty(Version)) PlayerSettings.bundleVersion = Version;
bool success = true;
switch (buildKind)
{
case BuildKind.Mac:
success &= BuildMac();
break;
case BuildKind.Windows:
success &= BuildWindows();
break;
case BuildKind.Both:
success &= BuildMac();
success &= BuildWindows();
break;
}
if (!success)
{
Debug.LogError("CI Build FAILED");
EditorApplication.Exit(1);
}
else
{
Debug.Log("CI Build SUCCESS");
EditorApplication.Exit(0);
}
}
#region Local Build Item
[MenuItem("Build/Run CI (Both)")]
public static void Menu_CI_Both()
{
if (!EditorUtility.DisplayDialog("Build", "Build Both (Mac + Windows)?", "Build", "Cancel")) return;
Development = false;
CleanOutput = true;
bool ok = BuildMac() & BuildWindows();
EditorUtility.DisplayDialog("Build", ok ? "SUCCESS" : "FAILED", "OK");
}
[MenuItem("Build/MacOS")]
public static void Menu_Mac() => BuildMac();
[MenuItem("Build/Windows")]
public static void Menu_Win() => BuildWindows();
#endregion
private static bool BuildMac()
{
string[] scenes = GetEnabledScenes();
string appName = string.IsNullOrEmpty(MacAppName) ? ProductName : MacAppName;
string outDir = Path.Combine(OutputRoot, "mac");
string appPath = Path.Combine(outDir, $"{appName}.app");
if (CleanOutput && Directory.Exists(outDir)) Directory.Delete(outDir, true);
TrySetScriptingBackend(BuildTargetGroup.Standalone, ScriptingImplementation.IL2CPP);
BuildPlayerOptions options = new BuildPlayerOptions
{
scenes = scenes,
locationPathName = appPath,
target = BuildTarget.StandaloneOSX,
options = BuildOptionsFromFlags()
};
EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.Standalone, BuildTarget.StandaloneOSX);
BuildReport report = BuildPipeline.BuildPlayer(options);
return LogReport("macOS", report);
}
private static bool BuildWindows()
{
string[] scenes = GetEnabledScenes();
string outDir = Path.Combine(OutputRoot, "win");
string exe = Path.Combine(outDir, $"{WinExeName}.exe");
if (CleanOutput && Directory.Exists(outDir)) Directory.Delete(outDir, true);
TrySetScriptingBackend(BuildTargetGroup.Standalone, ScriptingImplementation.Mono2x);
EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.Standalone, BuildTarget.StandaloneWindows64);
BuildPlayerOptions options = new BuildPlayerOptions
{
scenes = scenes,
locationPathName = exe,
target = BuildTarget.StandaloneWindows64,
options = BuildOptionsFromFlags()
};
BuildReport report = BuildPipeline.BuildPlayer(options);
return LogReport("Windows", report);
}
private static string[] GetEnabledScenes() => EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray();
private static BuildOptions BuildOptionsFromFlags()
{
BuildOptions opt = BuildOptions.None;
if (Development) opt |= BuildOptions.Development | BuildOptions.AllowDebugging | BuildOptions.ConnectWithProfiler;
return opt;
}
private static void TrySetScriptingBackend(BuildTargetGroup group, ScriptingImplementation impl)
{
try
{
PlayerSettings.SetScriptingBackend(group, impl);
}
catch (Exception e)
{
Debug.LogWarning($"SetScriptingBackend failed ({group}:{impl}): {e.Message}");
}
}
private static bool LogReport(string label, BuildReport report)
{
if (report == null)
{
Debug.LogError($"{label} build: report is null");
return false;
}
BuildSummary summary = report.summary;
string sizeMB = (summary.totalSize / (1024f * 1024f)).ToString("F1");
switch (summary.result)
{
case BuildResult.Succeeded:
Debug.Log($"✅ {label} build Succeeded | time: {summary.totalTime:mm\\:ss} | size: {sizeMB} MB\nOutput: {summary.outputPath}");
return true;
case BuildResult.Failed:
Debug.LogError($"❌ {label} build Failed | time: {summary.totalTime:mm\\:ss}");
break;
case BuildResult.Cancelled:
Debug.LogWarning($"⚠️ {label} build Cancelled");
break;
case BuildResult.Unknown:
Debug.LogWarning($"ℹ️ {label} build Unknown result");
break;
}
for (int i = report.steps.Length - 1; i >= 0; i--)
{
BuildStep s = report.steps[i];
for (int index = s.messages.Length - 1; index >= 0; index--)
{
BuildStepMessage m = s.messages[index];
if (m.type is LogType.Error or LogType.Exception) Debug.LogError($"[{s.name}] {m.content}");
}
}
return false;
}
private static string GetArg(string[] args, string key, string defaultValue = null)
{
for (int i = 0; i < args.Length; i++)
{
if (args[i].Equals(key, StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) return args[i + 1];
}
return defaultValue;
}
private static bool ParseBool(string s, bool def)
{
if (string.IsNullOrEmpty(s)) return def;
if (bool.TryParse(s, out var b)) return b;
string t = s.Trim().ToLowerInvariant();
return t is "1" or "yes" or "y" or "true";
}
private static T ParseEnum<T>(string s, T def) where T : struct
{
if (string.IsNullOrEmpty(s)) return def;
return Enum.TryParse(s, true, out T v) ? v : def;
}
public static void Smoke()
{
string p = Path.Combine("Builds", "smoke.txt");
Directory.CreateDirectory("Builds");
File.WriteAllText(p, $"hello {DateTime.Now:O}");
Debug.Log($"[Smoke] wrote: {Path.GetFullPath(p)}");
}
}
}
#endif
3. Smoke Test
먼저 Smoke() 메서드로 CLI 호출 성공을 확인한다.
public static void Smoke()
{
string p = Path.Combine("Builds", "smoke.txt");
Directory.CreateDirectory("Builds");
File.WriteAllText(p, $"hello {DateTime.Now:O}");
Debug.Log($"[Smoke] wrote: {Path.GetFullPath(p)}");
}
Editor command line arguments
-batchmode
-quit
-nographics
-logFile "$WORKSPACE/Editor.log"
-projectPath "$WORKSPACE"
-executeMethod Project.EditorTools.BuildProcess.Smoke
로그에 [Smoke] wrote: 가 보이면 CLI 정상 작동.
4. 트러블
| **증상** | **원인** | **해결** | | ———————————– | ————————- | ————————————————— | | “No valid Unity license” | 라이선스 미등록 | Jenkins 환경변수 UNITY_LICENSE_CONTENT 사용 혹은 수동 ULF 등록 | | “Another Unity instance is running” | 락파일 존재 또는 로컬 에디터 열림 | Pre Step 에서 Unity 종료 및 락파일 삭제 | | “executeMethod could not be found” | 네임스페이스 오류 또는 Editor 외부 폴더 | namespace Project.EditorTools, Assets/Editor 경로로 이동 | | “Native extension … not found” | 빌드 모듈 미설치 | mac-il2cpp, windows-mono 모듈 설치 | | Builds 폴더 없음 | 출력 폴더 미생성 또는 씬 0개 | Directory.CreateDirectory() 및 씬 체크 추가 |