Эта статья — практическое руководство по сборке Unity-проекта без нажатия кнопки Build. Вы напишете скрипт сборки на C#, научитесь запускать его из командной строки в режиме -batchmode, передавать свои аргументы (версию, тип сборки, путь), правильно сообщать об ошибке через код выхода и встраивать проверки в пайплайн через IPreprocessBuildWithReport. В конце будет готовый шаблон, который одинаково работает на вашей машине, на сборочном сервере и в GitHub Actions.
Зачем вообще собирать из командной строки
Пока проект маленький, кнопки Build в окне Build Profiles (в старых версиях — Build Settings) хватает. Проблемы начинаются, когда сборок становится много: dev-сборка для тестировщиков, релиз в Google Play, WebGL для демо, сервер для мультиплеера. Каждую нужно собрать с правильными галочками, правильной версией и правильными define-символами. Человек ошибается: забыл снять Development Build, не поднял bundleVersionCode, собрал из грязной ветки.
Скрипт сборки решает это раз и навсегда:
- Воспроизводимость. Одна команда — одна и та же сборка, независимо от того, кто и где её запускает.
- Автоматизация. Сборку можно повесить на коммит в main, на тег или на ночное расписание.
- Проверки. Перед сборкой можно убедиться, что в сцене нет отладочных объектов, что версия поднята, что все сцены на месте, — и уронить сборку, если что-то не так.
- Пост-обработка. После сборки — заархивировать, переименовать по версии, положить рядом changelog, отправить в хранилище.
Минимальный скрипт сборки
Всё начинается со статического метода в editor-сборке. Файл должен лежать в папке Editor (или в asmdef, ограниченном платформой Editor), иначе UnityEditor будет недоступен и игровая сборка просто не скомпилируется.
// Assets/Editor/BuildScript.cs
using System.Linq;
using UnityEditor;
using UnityEditor.Build.Reporting;
using UnityEngine;
public static class BuildScript
{
[MenuItem("Build/Windows (Release)")]
public static void BuildWindows()
{
var options = new BuildPlayerOptions
{
scenes = EditorBuildSettings.scenes
.Where(s => s.enabled)
.Select(s => s.path)
.ToArray(),
locationPathName = "Builds/Windows/MyGame.exe",
target = BuildTarget.StandaloneWindows64,
options = BuildOptions.None
};
BuildReport report = BuildPipeline.BuildPlayer(options);
BuildSummary summary = report.summary;
if (summary.result == BuildResult.Succeeded)
Debug.Log($"Сборка готова: {summary.totalSize / (1024 * 1024)} МБ за {summary.totalTime}");
else
Debug.LogError($"Сборка провалилась: {summary.result}, ошибок: {summary.totalErrors}");
}
}
Что здесь важно:
- Список сцен берётся из настроек проекта, а не хардкодится. Так скрипт не разъедется со списком сцен, который видят все остальные.
locationPathName— путь к исполняемому файлу, а не к папке, для Windows (.exe), macOS (.app) и Android (.apkили.aab). Для WebGL и iOS это путь к папке. Путь относительный — он считается от корня проекта.BuildPipeline.BuildPlayerвозвращаетBuildReport. Не игнорируйте его: именно поsummary.resultвы узнаёте, получилась ли сборка.
Атрибут [MenuItem] необязателен, но удобен: тот же метод можно запустить руками из меню редактора и убедиться, что он работает, прежде чем отдавать его серверу.
Запуск без окна редактора
Теперь вызовем этот метод из терминала. На Windows путь к редактору обычно выглядит как C:\Program Files\Unity\Hub\Editor\<версия>\Editor\Unity.exe, на macOS — /Applications/Unity/Hub/Editor/<версия>/Unity.app/Contents/MacOS/Unity.
"C:\Program Files\Unity\Hub\Editor\6000.0.58f1\Editor\Unity.exe" ^
-batchmode -quit -nographics ^
-projectPath "D:\Projects\MyGame" ^
-buildTarget StandaloneWindows64 ^
-executeMethod BuildScript.BuildWindows ^
-logFile "D:\Projects\MyGame\Builds\build.log"
Разберём аргументы:
-batchmode— редактор запускается без интерфейса. Никаких диалогов, никаких окон. Если код попытается показатьEditorUtility.DisplayDialog, в batchmode он сразу вернёт значение по умолчанию.-quit— закрыть редактор после выполнения метода. Без него процесс повиснет навсегда, и CI будет ждать до таймаута.-nographics— не инициализировать графическое устройство. Полезно на серверах без видеокарты. Но осторожно: если сборка запекает освещение или генерирует что-то через GPU, этот флаг придётся убрать.-projectPath— папка проекта (та, в которой лежатAssetsиProjectSettings).-buildTarget— платформа, на которую редактор переключится при открытии проекта. Об этом подробнее ниже.-executeMethod— полное имя статического метода:Пространство.Класс.Метод. Метод должен быть статическим, без параметров.-logFile— куда писать лог. Если передать-logFile -(дефис), лог пойдёт в стандартный вывод, что удобно в CI: всё видно прямо в консоли джобы.
Почему не переключать платформу внутри скрипта
Соблазнительно написать в начале метода EditorUserBuildSettings.SwitchActiveBuildTarget(...) и не думать о флагах. Так делать не стоит. Переключение платформы запускает реимпорт ассетов и перекомпиляцию скриптов, а в batchmode после перекомпиляции домен перезагружается уже после того, как ваш метод начал выполняться. Итог — нестабильные сборки, в которых часть кода собрана со старыми define-символами. Платформу правильно задавать флагом -buildTarget: редактор откроет проект сразу в нужном режиме, и к моменту вызова вашего метода всё уже импортировано и скомпилировано.
Отсюда же практический совет для CI: держите отдельную рабочую копию (или кэш папки Library) на каждую платформу. Переключение Windows → Android на большом проекте может занимать десятки минут, а с готовым кэшем — секунды.
Код выхода: как сказать CI, что сборка упала
Самая частая ошибка в самописных скриптах — сборка проваливается, а процесс Unity завершается с кодом 0, и CI рапортует зелёную галочку. Причина в том, что Debug.LogError не влияет на код выхода. Нужно завершать процесс явно:
private static void Finish(BuildReport report)
{
BuildSummary summary = report.summary;
Debug.Log($"Результат: {summary.result}, размер: {summary.totalSize} байт, время: {summary.totalTime}");
if (!Application.isBatchMode)
return; // запуск из меню — редактор не закрываем
EditorApplication.Exit(summary.result == BuildResult.Succeeded ? 0 : 1);
}
EditorApplication.Exit(int) закрывает редактор с указанным кодом. Проверка Application.isBatchMode нужна, чтобы при запуске из меню скрипт не закрыл вам редактор с несохранённой сценой.
Ещё одна страховка: если внутри -executeMethod вылетело необработанное исключение, Unity в batchmode с -quit завершится с ненулевым кодом. Поэтому в собственных проверках смело бросайте исключение — это проще, чем протаскивать флаг ошибки через весь код.
Свои аргументы командной строки
Метод для -executeMethod не принимает параметров, но все аргументы процесса доступны через System.Environment.GetCommandLineArgs(). Unity игнорирует незнакомые ему флаги, поэтому можно добавлять свои. Напишем маленький парсер:
using System;
using System.Collections.Generic;
public static class BuildArgs
{
private static Dictionary<string, string> _cache;
public static string Get(string name, string fallback = null)
{
_cache ??= Parse();
return _cache.TryGetValue(name, out var value) ? value : fallback;
}
public static bool Has(string name)
{
_cache ??= Parse();
return _cache.ContainsKey(name);
}
private static Dictionary<string, string> Parse()
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string[] args = Environment.GetCommandLineArgs();
for (int i = 0; i < args.Length; i++)
{
if (!args[i].StartsWith("-")) continue;
string key = args[i].TrimStart('-');
bool hasValue = i + 1 < args.Length && !args[i + 1].StartsWith("-");
result[key] = hasValue ? args[++i] : "true";
}
return result;
}
}
Теперь в командную строку можно дописать, например, -buildVersion 1.4.2 -buildNumber 87 -dev -outputPath Builds/Android/game.aab и читать это в скрипте через BuildArgs.Get("buildVersion") и BuildArgs.Has("dev").
Чтобы не столкнуться с встроенными флагами Unity, давайте своим аргументам узнаваемый префикс или составные имена: -buildVersion, а не -version. Заметьте ещё одну тонкость парсера: отрицательные числа вида -5 он примет за флаг. Для версий и путей это не проблема, но если понадобятся отрицательные значения, передавайте их в форме -offset=-5 и разбирайте по знаку равенства.
Версия, номер сборки и define-символы
Скрипт сборки — правильное место, чтобы проставить версию. Тогда она всегда совпадает с тем, что знает CI (тег, номер запуска пайплайна), и никто не забудет её поднять.
private static void ApplyVersion()
{
string version = BuildArgs.Get("buildVersion");
string number = BuildArgs.Get("buildNumber");
if (!string.IsNullOrEmpty(version))
PlayerSettings.bundleVersion = version;
if (int.TryParse(number, out int code))
{
PlayerSettings.Android.bundleVersionCode = code;
PlayerSettings.iOS.buildNumber = code.ToString();
}
}
Помните, что PlayerSettings — это ассет проекта (ProjectSettings/ProjectSettings.asset). Изменения из скрипта сохранятся в файле, и на локальной машине вы увидите их в git как изменённый файл. На CI это обычно неважно, рабочая копия всё равно одноразовая. Локально же можно либо не коммитить эти изменения, либо в конце скрипта возвращать старые значения.
С define-символами то же самое. Например, вы хотите, чтобы в dev-сборке была включена внутриигровая консоль, а в релизе — нет. Современный API работает с NamedBuildTarget:
using UnityEditor.Build;
private static void ApplyDefines(BuildTarget target, bool dev)
{
var group = BuildPipeline.GetBuildTargetGroup(target);
var named = NamedBuildTarget.FromBuildTargetGroup(group);
string current = PlayerSettings.GetScriptingDefineSymbols(named);
var symbols = new HashSet<string>(current.Split(';', StringSplitOptions.RemoveEmptyEntries));
if (dev) symbols.Add("DEV_CONSOLE");
else symbols.Remove("DEV_CONSOLE");
PlayerSettings.SetScriptingDefineSymbols(named, string.Join(";", symbols));
}
Здесь есть ловушка, связанная с той же перекомпиляцией. Изменение define-символов запускает компиляцию скриптов, но сам код скрипта сборки уже выполняется. Сборка игрока при этом компилирует игровые скрипты заново, с новыми символами, так что в плеер они попадут правильно. А вот код вашего editor-скрипта, который проверяет #if DEV_CONSOLE, увидит символы только после следующей перезагрузки домена. Правило простое: не полагайтесь на #if внутри editor-кода сборки, читайте настройки из аргументов.
Альтернатива, которая обходит ловушку целиком, — поле extraScriptingDefines в BuildPlayerOptions. Символы из него добавляются только для этой конкретной сборки и не меняют настройки проекта:
options.extraScriptingDefines = dev
? new[] { "DEV_CONSOLE", "ENABLE_CHEATS" }
: Array.Empty<string>();
Это предпочтительный вариант: файл ProjectSettings.asset не меняется, и в git ничего не всплывает.
Dev и Release: флаги BuildOptions
BuildOptions — это enum с атрибутом [Flags], значения комбинируются через побитовое ИЛИ. Самые нужные:
Development— development-сборка: работаютDebug.Logс полными стектрейсами, доступен профайлер, в углу экрана отладочная надпись.AllowDebugging— разрешить подключение отладчика к скриптам (только вместе сDevelopment).ConnectWithProfiler— плеер при старте сам подключится к профайлеру редактора.DetailedBuildReport— собрать подробный отчёт о том, что и почему попало в сборку. Полезно, когда надо понять, откуда взялись лишние сто мегабайт.StrictMode— не считать сборку успешной, если в процессе были ошибки. Без этого флага часть ошибок (например, в отдельных шейдерах) может закончиться «успешной» сборкой с битым содержимым.CleanBuildCache— собрать с нуля, игнорируя кэш. Медленно, но спасает от редких странных проблем после обновления Unity.
private static BuildOptions GetOptions(bool dev)
{
var options = BuildOptions.StrictMode;
if (dev)
options |= BuildOptions.Development | BuildOptions.AllowDebugging;
if (BuildArgs.Has("cleanBuild"))
options |= BuildOptions.CleanBuildCache;
return options;
}
Build Profiles в Unity 6
В Unity 6 появились Build Profiles — ассеты, которые хранят платформу, список сцен, define-символы и часть настроек конкретной сборки. Вместо одной глобальной конфигурации у вас может быть «Android Dev», «Android Release» и «WebGL Demo», каждая в своём файле в проекте.
Для командной строки это означает два новых способа:
-activeBuildProfile "Assets/Settings/Build Profiles/Android Release.asset"вместе с-build "Builds/Android/game.aab"— собрать по профилю вообще без своего скрипта.-activeBuildProfileвместе с-executeMethod— профиль задаёт платформу и настройки, а ваш скрипт добавляет версию, проверки и пост-обработку.
Во втором случае в скрипте используется перегрузка BuildPipeline.BuildPlayer, принимающая BuildPlayerWithProfileOptions:
using UnityEditor.Build.Profile;
public static void BuildWithActiveProfile()
{
BuildProfile profile = BuildProfile.GetActiveBuildProfile();
if (profile == null)
throw new BuildFailedException("Не задан профиль: передайте -activeBuildProfile");
var options = new BuildPlayerWithProfileOptions
{
buildProfile = profile,
locationPathName = BuildArgs.Get("outputPath", "Builds/Default/game"),
options = GetOptions(BuildArgs.Has("dev"))
};
Finish(BuildPipeline.BuildPlayer(options));
}
Если проект ещё на Unity 2022 LTS или вы не хотите заводить профили, классический BuildPlayerOptions с -buildTarget продолжает работать и в Unity 6. Профили удобны, когда конфигураций много и их хочется держать в виде ассетов, а не в виде веток if в коде.
Проверки перед сборкой: IPreprocessBuildWithReport
Бывают проверки, которые должны срабатывать при любой сборке — и из скрипта, и по кнопке в редакторе. Для этого есть колбэки сборки. Класс, реализующий IPreprocessBuildWithReport, Unity находит сам и вызывает перед каждой сборкой плеера.
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
public class ReleaseChecks : IPreprocessBuildWithReport
{
public int callbackOrder => 0;
public void OnPreprocessBuild(BuildReport report)
{
bool dev = (report.summary.options & BuildOptions.Development) != 0;
if (dev) return;
if (PlayerSettings.bundleVersion == "0.1")
throw new BuildFailedException("Релиз с версией по умолчанию 0.1 — поднимите bundleVersion");
if (report.summary.platform == BuildTarget.Android && !PlayerSettings.Android.useCustomKeystore)
throw new BuildFailedException("Релиз под Android должен подписываться своим keystore");
}
}
Ключевой момент — BuildFailedException. Это специальное исключение, которое корректно останавливает сборку: Unity прервёт процесс, а BuildReport получит статус Failed. Обычное исключение в колбэке в некоторых версиях Unity просто логируется, и сборка идёт дальше.
callbackOrder задаёт порядок, если колбэков несколько: меньшее число вызывается раньше. Это пригодится, когда один колбэк, например, генерирует файл, а второй его проверяет.
Парный интерфейс — IPostprocessBuildWithReport. Он вызывается после сборки и получает тот же BuildReport, в том числе summary.outputPath. Туда удобно вынести всё, что делается с готовыми файлами: скопировать конфиг рядом с exe, записать файл с номером версии, удалить папку _BurstDebugInformation_DoNotShip, которая не должна попасть к игрокам.
using System.IO;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEngine;
public class StripDebugFolders : IPostprocessBuildWithReport
{
public int callbackOrder => 100;
public void OnPostprocessBuild(BuildReport report)
{
string outputDir = Path.GetDirectoryName(report.summary.outputPath);
if (string.IsNullOrEmpty(outputDir) || !Directory.Exists(outputDir)) return;
foreach (string dir in Directory.GetDirectories(outputDir, "*_DoNotShip"))
{
Directory.Delete(dir, true);
Debug.Log($"Удалена отладочная папка: {dir}");
}
}
}
Подпись Android-сборки без паролей в репозитории
Релизная Android-сборка должна быть подписана вашим ключом. Хранить пароль от keystore в ProjectSettings.asset нельзя: Unity и так не сохраняет пароли в проекте, а значит, на CI их надо передать снаружи. Лучший способ — переменные окружения, которые CI подставляет из секретов:
private static void ApplyAndroidSigning()
{
string keystore = Environment.GetEnvironmentVariable("ANDROID_KEYSTORE_PATH");
if (string.IsNullOrEmpty(keystore))
return; // локальная сборка — подпись отладочным ключом
PlayerSettings.Android.useCustomKeystore = true;
PlayerSettings.Android.keystoreName = keystore;
PlayerSettings.Android.keystorePass = Environment.GetEnvironmentVariable("ANDROID_KEYSTORE_PASS");
PlayerSettings.Android.keyaliasName = Environment.GetEnvironmentVariable("ANDROID_KEY_ALIAS");
PlayerSettings.Android.keyaliasPass = Environment.GetEnvironmentVariable("ANDROID_KEY_ALIAS_PASS");
}
Переменные окружения, а не аргументы командной строки, выбраны не случайно. Аргументы процесса видны в списке процессов и часто попадают в лог джобы целиком, а секреты из переменных окружения CI-системы маскируют в логах автоматически.
Для AAB-формата (обязательного для Google Play) включите EditorUserBuildSettings.buildAppBundle = true и указывайте путь с расширением .aab.
Собираем всё вместе
Вот итоговый метод, который объединяет всё сказанное. Он читает аргументы, ставит версию, подпись и define-символы, собирает под платформу, на которую открыт редактор, и возвращает правильный код выхода.
public static void BuildFromCommandLine()
{
try
{
BuildTarget target = EditorUserBuildSettings.activeBuildTarget;
bool dev = BuildArgs.Has("dev");
ApplyVersion();
if (target == BuildTarget.Android)
{
ApplyAndroidSigning();
EditorUserBuildSettings.buildAppBundle = !dev;
}
string output = BuildArgs.Get("outputPath") ?? DefaultPath(target, dev);
var options = new BuildPlayerOptions
{
scenes = EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray(),
locationPathName = output,
target = target,
options = GetOptions(dev),
extraScriptingDefines = dev ? new[] { "DEV_CONSOLE" } : Array.Empty<string>()
};
if (options.scenes.Length == 0)
throw new BuildFailedException("В Build Settings нет ни одной включённой сцены");
Finish(BuildPipeline.BuildPlayer(options));
}
catch (Exception e)
{
Debug.LogException(e);
if (Application.isBatchMode)
EditorApplication.Exit(1);
}
}
private static string DefaultPath(BuildTarget target, bool dev)
{
string kind = dev ? "dev" : "release";
string version = PlayerSettings.bundleVersion;
return target switch
{
BuildTarget.StandaloneWindows64 => $"Builds/{kind}/Windows/{Application.productName}.exe",
BuildTarget.StandaloneOSX => $"Builds/{kind}/macOS/{Application.productName}.app",
BuildTarget.Android => $"Builds/{kind}/Android/game-{version}.{(dev ? "apk" : "aab")}",
BuildTarget.WebGL => $"Builds/{kind}/WebGL",
_ => $"Builds/{kind}/{target}"
};
}
Обратите внимание на EditorUserBuildSettings.activeBuildTarget: метод не решает, под какую платформу собирать, он просто берёт ту, на которую редактор открыт флагом -buildTarget. Один метод — все платформы, а выбор платформы остаётся за командной строкой.
Пример для GitHub Actions
Собирать Unity на облачных раннерах GitHub можно через готовые экшены сообщества GameCI (game-ci/unity-builder): они берут на себя установку редактора в Docker-контейнере и активацию лицензии. Наш скрипт с ними совместим: у экшена есть параметр buildMethod, куда передаётся имя статического метода, и customParameters для своих аргументов. Если же у вас свой раннер с установленным Unity, всё сводится к одному шагу:
jobs:
android:
runs-on: [self-hosted, unity]
steps:
- uses: actions/checkout@v4
with:
lfs: true
- uses: actions/cache@v4
with:
path: Library
key: library-android-${{ hashFiles('Packages/packages-lock.json') }}
- name: Build
env:
ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/release.keystore
ANDROID_KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_ALIAS_PASS: ${{ secrets.ANDROID_KEY_ALIAS_PASS }}
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$ANDROID_KEYSTORE_PATH"
"$UNITY_PATH" -batchmode -quit -nographics \
-projectPath . \
-buildTarget Android \
-executeMethod BuildScript.BuildFromCommandLine \
-buildVersion 1.4.${{ github.run_number }} \
-buildNumber ${{ github.run_number }} \
-logFile -
- uses: actions/upload-artifact@v4
with:
name: android-release
path: Builds/release/Android/
Несколько деталей, которые экономят часы:
- Кэш
Libraryпривязан к платформе в ключе. Кэш от Windows-сборки для Android не подходит и только замедлит первый импорт. - Keystore хранится в секрете в base64 и разворачивается во временную папку раннера, а не в папку проекта, откуда он мог бы случайно попасть в артефакты.
github.run_numberмонотонно растёт, поэтому годится какbundleVersionCode: Google Play не примет сборку с кодом меньше или равным уже загруженному.-logFile -выводит лог Unity в консоль джобы. Без этого при падении вы увидите только «exit code 1» без объяснений.
Частые проблемы и как их узнать по логу
Процесс завершился мгновенно, сборки нет
Почти всегда это ошибка в имени метода. Unity пишет в лог, что не может найти метод для -executeMethod. Проверьте полное имя с пространством имён и что метод публичный и статический. Второй вариант — ошибки компиляции: если в проекте есть хоть одна ошибка компиляции, editor-сборка с вашим скриптом не загрузится, и вызывать будет нечего. Ищите в логе строки с error CS.
Другой процесс Unity уже открыл этот проект
Unity не позволяет открыть один проект двумя редакторами. Если вы запускаете сборку локально, закройте редактор с этим проектом. На CI это обычно значит, что предыдущая джоба зависла: добавьте таймаут на шаг сборки.
Сборка «успешна», но поведение как у dev-сборки или наоборот
Проверьте, не переключаете ли вы define-символы через PlayerSettings и не проверяете ли их через #if в editor-коде. Перейдите на extraScriptingDefines и чтение аргументов.
Первая сборка на сервере идёт очень долго
Это импорт всех ассетов с нуля в пустую папку Library. Настройте кэш, как в примере выше. Если у проекта много текстур, имеет смысл посмотреть и в сторону Unity Accelerator — кэш-сервера импорта, который делят все машины команды.
Лицензия
Batchmode тоже требует активированной лицензии Unity. На собственном раннере достаточно один раз войти в Unity Hub под пользователем, от которого работает раннер. В контейнерах лицензию активируют через переменные окружения, — экшены GameCI описывают этот процесс в своей документации.
Что в итоге
Скрипт сборки — это пара сотен строк, которые окупаются уже на второй неделе. Минимальный набор, с которого стоит начать:
- Статический метод в папке
Editor, который собирает черезBuildPipeline.BuildPlayerи смотрит наBuildReport. - Запуск с
-batchmode -quit -buildTarget ... -executeMethod ... -logFile -и явныйEditorApplication.Exitс кодом 1 при ошибке. - Версия и номер сборки — из аргументов, define-символы — через
extraScriptingDefines, пароли — из переменных окружения. - Проверки, общие для всех сборок, — в
IPreprocessBuildWithReportсBuildFailedException.
Когда это заработает локально одной командой, перенос в CI займёт полчаса. А дальше можно наращивать: собирать несколько платформ параллельно, отправлять готовые сборки тестировщикам, публиковать в магазины. Основа у всего этого одна — сборка, которая запускается без единого клика.