578 lines
24 KiB
C#
578 lines
24 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
using Unity.Pipeline.Commands;
|
|
using Unity.Pipeline.Editor.Authoring;
|
|
using UnityEditor;
|
|
using UnityEditor.Build;
|
|
using UnityEditor.Build.Reporting;
|
|
using UnityEngine;
|
|
|
|
namespace Unity.Pipeline.Editor.Commands.Build
|
|
{
|
|
/// <summary>
|
|
/// Triggers a Player build and reports the full <see cref="BuildReport"/> (CLI-204). Fills out the
|
|
/// original <c>build</c>/<c>build_status</c> stub (CLI-127) with the complete param contract and the
|
|
/// structured report shape so build failures can be diagnosed without the Editor UI.
|
|
///
|
|
/// Why this is async (trigger-then-poll, mirroring <see cref="RecompileCommand"/> /
|
|
/// <see cref="PackageManager.PackageManagerCommand"/>): <see cref="BuildPipeline.BuildPlayer(BuildPlayerOptions)"/>
|
|
/// runs synchronously and freezes the main thread for the whole build (seconds to minutes). If
|
|
/// <c>build</c> ran it inline it would block the server's (serial) request loop and the call could
|
|
/// never return. Instead <c>build</c> validates + queues and returns <c>queued</c> immediately; an
|
|
/// <see cref="EditorApplication.update"/> hook runs the build on the next tick. Poll <c>build_status</c>
|
|
/// until <c>status == "completed"</c>.
|
|
///
|
|
/// <c>build_status</c> is deliberately <c>MainThreadRequired = false</c>: while the build holds the
|
|
/// main thread, any main-thread command (e.g. editor_status) would hang, but build_status only reads
|
|
/// a Temp status file off-thread, so polling keeps working throughout the build. The status file also
|
|
/// survives the domain reload a build can incur, so the last report is retained until the next build.
|
|
/// </summary>
|
|
[InitializeOnLoad]
|
|
public static class BuildCommand
|
|
{
|
|
const string StatusFile = "Temp/pipeline_build_status.json";
|
|
|
|
// BuildOptions that callers may pass by name (the supported set from the ticket). Parsed
|
|
// case-insensitively; anything outside this set is a validation error rather than silently
|
|
// tolerated, so a typo surfaces instead of changing the build shape.
|
|
static readonly Dictionary<string, BuildOptions> s_SupportedOptions =
|
|
new Dictionary<string, BuildOptions>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["Development"] = BuildOptions.Development,
|
|
["AllowDebugging"] = BuildOptions.AllowDebugging,
|
|
["ConnectWithProfiler"] = BuildOptions.ConnectWithProfiler,
|
|
// EnableHeadlessMode is obsolete in Unity 6 (superseded by StandaloneBuildSubtarget.Server)
|
|
// but still functions and is part of this command's documented option set (CLI-204), so we
|
|
// keep accepting it and suppress only the deprecation warning here.
|
|
#pragma warning disable 618
|
|
["EnableHeadlessMode"] = BuildOptions.EnableHeadlessMode,
|
|
#pragma warning restore 618
|
|
["SymlinkSources"] = BuildOptions.SymlinkSources,
|
|
["BuildAdditionalStreamedScenes"] = BuildOptions.BuildAdditionalStreamedScenes,
|
|
["CleanBuildCache"] = BuildOptions.CleanBuildCache,
|
|
["DetailedBuildReport"] = BuildOptions.DetailedBuildReport,
|
|
};
|
|
|
|
static readonly object s_Lock = new object();
|
|
static bool s_Pending;
|
|
static bool s_Building;
|
|
|
|
// Queued request, captured under s_Lock by build() and consumed by the update pump.
|
|
static BuildTarget s_PendingTarget;
|
|
static string s_PendingOutput;
|
|
static BuildOptions s_PendingOptions;
|
|
static string[] s_PendingScenes;
|
|
static string s_PendingProfile;
|
|
static string s_PendingBuildId;
|
|
|
|
static BuildCommand()
|
|
{
|
|
EditorApplication.update += OnUpdate;
|
|
}
|
|
|
|
[CliCommand("build",
|
|
"Trigger an async Player build and report the full BuildReport. Returns immediately (queued); " +
|
|
"poll build_status until status is 'completed'. DetailedBuildReport is included by default unless " +
|
|
"'options' is supplied. Use dry_run to validate without building.",
|
|
MainThreadRequired = false)]
|
|
public static object Build(
|
|
[CliArg("target", "BuildTarget name (e.g. StandaloneWindows64). Defaults to the active target. Must be installed.")] string target = "",
|
|
[CliArg("outputPath", "Output path (absolute, or relative to the project root). Defaults to the last/auto path.")] string outputPath = "",
|
|
[CliArg("profileName", "Build Profile name to activate before building (Unity 6 only; ignored otherwise).")] string profileName = "",
|
|
[CliArg("options", "BuildOptions names. Omit to get just DetailedBuildReport; supplying any disables that default.")] string[] options = null,
|
|
[CliArg("scenes", "Scene asset paths to build (e.g. Assets/Scenes/Main.unity). Defaults to EditorBuildSettings.")] string[] scenes = null,
|
|
[CliArg("confirm", "Acknowledge and run the build; without it the call is refused. Use dry_run to validate only.")] bool confirm = false,
|
|
[CliArg("dry_run", "Validate target/outputPath/scenes without building.")] bool dryRun = false)
|
|
{
|
|
// Validation + queueing both touch Unity APIs, so run them on the main thread. When invoked
|
|
// off-thread (the normal HTTP path) this marshals through the server dispatcher; in-process
|
|
// (tests, no server) it runs inline.
|
|
return RunOnMain<object>(() => ValidateAndQueue(target, outputPath, profileName, options, scenes, confirm, dryRun));
|
|
}
|
|
|
|
static object ValidateAndQueue(string targetArg, string outputArg, string profileName,
|
|
string[] optionArgs, string[] sceneArgs, bool confirm, bool dryRun)
|
|
{
|
|
lock (s_Lock)
|
|
{
|
|
if (!dryRun && (s_Building || s_Pending))
|
|
return new { status = "busy", success = false, message = "A build is already queued or in progress. Poll build_status." };
|
|
}
|
|
|
|
var errors = new List<object>();
|
|
|
|
// ---- target ----
|
|
var target = EditorUserBuildSettings.activeBuildTarget;
|
|
if (!string.IsNullOrWhiteSpace(targetArg))
|
|
{
|
|
if (!TryParseTarget(targetArg, out target))
|
|
errors.Add(Invalid("target", $"Unknown BuildTarget '{targetArg}'. Use a name from list_build_targets."));
|
|
else if (!BuildPipeline.IsBuildTargetSupported(BuildPipeline.GetBuildTargetGroup(target), target))
|
|
errors.Add(Invalid("target", $"Build target '{target}' is not installed (isInstalled=false in list_build_targets)."));
|
|
}
|
|
|
|
// ---- options ----
|
|
var options = BuildOptions.None;
|
|
if (optionArgs != null && optionArgs.Length > 0)
|
|
{
|
|
foreach (var name in optionArgs)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
continue;
|
|
if (s_SupportedOptions.TryGetValue(name.Trim(), out var opt))
|
|
options |= opt;
|
|
else
|
|
errors.Add(Invalid("options", $"Unsupported BuildOptions value '{name}'."));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Strongly recommended: without it BuildReport.packedAssets is empty.
|
|
options = BuildOptions.DetailedBuildReport;
|
|
}
|
|
|
|
// ---- scenes ----
|
|
var scenes = (sceneArgs != null && sceneArgs.Length > 0)
|
|
? sceneArgs
|
|
: EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray();
|
|
|
|
if (sceneArgs != null)
|
|
{
|
|
foreach (var scene in sceneArgs)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(scene) || string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(scene)))
|
|
errors.Add(Invalid("scenes", $"No scene asset at '{scene}'."));
|
|
}
|
|
}
|
|
|
|
// ---- output path ----
|
|
// Non-mutating writability check: resolve the path and confirm an existing ancestor
|
|
// directory exists (so the build's output folder can be created). BuildPipeline.BuildPlayer
|
|
// creates the leaf directory itself, so we never create anything here — a dry run stays pure.
|
|
string outputPath = null;
|
|
try
|
|
{
|
|
outputPath = ResolveOutput(outputArg, target);
|
|
if (!HasExistingAncestor(outputPath))
|
|
errors.Add(Invalid("outputPath", $"No existing parent directory for '{outputPath}'."));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors.Add(Invalid("outputPath", $"Output path is invalid: {ex.Message}"));
|
|
}
|
|
|
|
if (dryRun)
|
|
{
|
|
return new
|
|
{
|
|
status = "dry_run",
|
|
valid = errors.Count == 0,
|
|
validationErrors = errors
|
|
};
|
|
}
|
|
|
|
if (errors.Count > 0)
|
|
{
|
|
// Fail immediately — do not queue (acceptance criteria 5).
|
|
return new { status = "error", success = false, validationErrors = errors, message = "Build configuration is invalid; nothing was queued." };
|
|
}
|
|
|
|
if (!confirm)
|
|
return new
|
|
{
|
|
status = "error",
|
|
success = false,
|
|
message = "Refused: this triggers a player build. Pass confirm=true to build, or dry_run=true to validate without building."
|
|
};
|
|
|
|
var buildId = NewBuildId();
|
|
|
|
lock (s_Lock)
|
|
{
|
|
s_PendingTarget = target;
|
|
s_PendingOutput = outputPath;
|
|
s_PendingOptions = options;
|
|
s_PendingScenes = scenes;
|
|
s_PendingProfile = profileName;
|
|
s_PendingBuildId = buildId;
|
|
s_Pending = true;
|
|
}
|
|
|
|
WriteStatusJson(new { status = "queued", buildId, message = "Build queued. Poll build_status until status is 'completed'." });
|
|
|
|
return new { status = "queued", buildId };
|
|
}
|
|
|
|
[CliCommand("build_status",
|
|
"Status of the current/most recent build: idle | queued | building | completed, with the full " +
|
|
"BuildReport (files, packedAssets, buildSteps, errors, warnings) once completed. Retained until the next build.",
|
|
MainThreadRequired = false)]
|
|
public static string BuildStatus()
|
|
{
|
|
if (!File.Exists(StatusFile))
|
|
return "{\"status\":\"idle\"}";
|
|
|
|
var text = File.ReadAllText(StatusFile);
|
|
|
|
// While building, recompute elapsedMs at read time so polling shows live progress.
|
|
try
|
|
{
|
|
var doc = JObject.Parse(text);
|
|
if ((string)doc["status"] == "building")
|
|
{
|
|
long elapsed = 0;
|
|
var startedStr = (string)doc["buildStartedAt"];
|
|
if (!string.IsNullOrEmpty(startedStr) &&
|
|
DateTime.TryParse(startedStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var started))
|
|
{
|
|
elapsed = (long)Math.Max(0, (DateTime.UtcNow - started.ToUniversalTime()).TotalMilliseconds);
|
|
}
|
|
|
|
return JsonConvert.SerializeObject(new
|
|
{
|
|
status = "building",
|
|
buildId = (string)doc["buildId"],
|
|
elapsedMs = elapsed
|
|
});
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Malformed file — fall through and return it verbatim.
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main-thread pump: picks up a queued request and runs the (blocking) build. Guarded so a build
|
|
/// runs at most once at a time.
|
|
/// </summary>
|
|
static void OnUpdate()
|
|
{
|
|
BuildTarget target;
|
|
string output;
|
|
BuildOptions options;
|
|
string[] scenes;
|
|
string profile;
|
|
string buildId;
|
|
|
|
lock (s_Lock)
|
|
{
|
|
if (!s_Pending || s_Building)
|
|
return;
|
|
s_Pending = false;
|
|
s_Building = true;
|
|
target = s_PendingTarget;
|
|
output = s_PendingOutput;
|
|
options = s_PendingOptions;
|
|
scenes = s_PendingScenes;
|
|
profile = s_PendingProfile;
|
|
buildId = s_PendingBuildId;
|
|
}
|
|
|
|
try
|
|
{
|
|
RunBuild(buildId, target, output, options, scenes, profile);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
WriteStatusJson(new
|
|
{
|
|
status = "completed",
|
|
buildId,
|
|
result = "Failed",
|
|
totalSizeBytes = 0,
|
|
errors = new[] { new { message = $"Build failed to start: {ex.Message}" } },
|
|
warnings = Array.Empty<object>()
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
lock (s_Lock) { s_Building = false; }
|
|
}
|
|
}
|
|
|
|
static void RunBuild(string buildId, BuildTarget target, string outputPath, BuildOptions options,
|
|
string[] scenes, string profileName)
|
|
{
|
|
var startedAt = DateTime.UtcNow;
|
|
WriteStatusJson(new
|
|
{
|
|
status = "building",
|
|
buildId,
|
|
buildStartedAt = startedAt.ToString("o"),
|
|
platform = target.ToString(),
|
|
outputPath
|
|
});
|
|
|
|
// Best-effort Build Profile activation (Unity 6 only). Never fails the build.
|
|
if (!string.IsNullOrWhiteSpace(profileName))
|
|
TryActivateBuildProfile(profileName);
|
|
|
|
var buildOptions = new BuildPlayerOptions
|
|
{
|
|
scenes = scenes,
|
|
locationPathName = outputPath,
|
|
target = target,
|
|
targetGroup = BuildPipeline.GetBuildTargetGroup(target),
|
|
options = options
|
|
};
|
|
|
|
var report = BuildPipeline.BuildPlayer(buildOptions);
|
|
WriteStatusJson(Project(buildId, report));
|
|
}
|
|
|
|
// ---- BuildReport projection ------------------------------------------------------------
|
|
|
|
static BuildReportResult Project(string buildId, BuildReport report)
|
|
{
|
|
var summary = report.summary;
|
|
var success = summary.result == BuildResult.Succeeded;
|
|
var started = summary.buildStartedAt;
|
|
var ended = started + summary.totalTime;
|
|
|
|
var result = new BuildReportResult
|
|
{
|
|
Status = "completed",
|
|
BuildId = buildId,
|
|
Result = summary.result.ToString(),
|
|
Platform = summary.platform.ToString(),
|
|
OutputPath = summary.outputPath,
|
|
TotalSizeBytes = (long)summary.totalSize,
|
|
BuildTimeMs = (long)summary.totalTime.TotalMilliseconds,
|
|
BuildStartedAt = started.ToUniversalTime().ToString("o"),
|
|
BuildEndedAt = ended.ToUniversalTime().ToString("o"),
|
|
TotalWarnings = summary.totalWarnings,
|
|
TotalErrors = summary.totalErrors,
|
|
BuildSteps = ProjectSteps(report, out var errors, out var warnings),
|
|
Errors = errors,
|
|
Warnings = warnings
|
|
};
|
|
|
|
// Files / packed assets are only meaningful on success (and packedAssets needs DetailedBuildReport).
|
|
if (success)
|
|
{
|
|
result.Files = ProjectFiles(report);
|
|
result.PackedAssets = ProjectPackedAssets(report);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
static List<BuildFileEntry> ProjectFiles(BuildReport report)
|
|
{
|
|
var files = new List<BuildFileEntry>();
|
|
try
|
|
{
|
|
foreach (var f in report.GetFiles())
|
|
files.Add(new BuildFileEntry { Path = f.path, Role = f.role, SizeBytes = (long)f.size });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogWarning($"[pipeline] build file projection failed: {ex.Message}");
|
|
}
|
|
return files;
|
|
}
|
|
|
|
static List<PackedAssetEntry> ProjectPackedAssets(BuildReport report)
|
|
{
|
|
var packed = new List<PackedAssetEntry>();
|
|
if (report.packedAssets == null)
|
|
return packed;
|
|
|
|
foreach (var bundle in report.packedAssets)
|
|
{
|
|
var entry = new PackedAssetEntry
|
|
{
|
|
BundlePath = bundle.shortPath,
|
|
OverheadBytes = (long)bundle.overhead,
|
|
Contents = new List<PackedAssetContent>()
|
|
};
|
|
|
|
if (bundle.contents != null)
|
|
{
|
|
foreach (var c in bundle.contents)
|
|
{
|
|
entry.Contents.Add(new PackedAssetContent
|
|
{
|
|
AssetPath = c.sourceAssetPath,
|
|
Type = c.type != null ? c.type.Name : null,
|
|
Guid = c.sourceAssetGUID.ToString(),
|
|
SizeBytes = (long)c.packedSize
|
|
});
|
|
}
|
|
}
|
|
|
|
packed.Add(entry);
|
|
}
|
|
return packed;
|
|
}
|
|
|
|
static List<BuildStepEntry> ProjectSteps(BuildReport report,
|
|
out List<BuildIssue> errors, out List<BuildIssue> warnings)
|
|
{
|
|
errors = new List<BuildIssue>();
|
|
warnings = new List<BuildIssue>();
|
|
|
|
var steps = new List<BuildStepEntry>();
|
|
if (report.steps == null)
|
|
return steps;
|
|
|
|
foreach (var step in report.steps)
|
|
{
|
|
var entry = new BuildStepEntry
|
|
{
|
|
Name = step.name,
|
|
DurationMs = (long)step.duration.TotalMilliseconds,
|
|
Depth = (int)step.depth,
|
|
Messages = new List<BuildStepMessageEntry>()
|
|
};
|
|
|
|
if (step.messages != null)
|
|
{
|
|
foreach (var m in step.messages)
|
|
{
|
|
entry.Messages.Add(new BuildStepMessageEntry { Type = MapLogType(m.type), Content = m.content });
|
|
|
|
if (m.type == LogType.Error || m.type == LogType.Assert || m.type == LogType.Exception)
|
|
errors.Add(BuildIssue.From(m.content));
|
|
else if (m.type == LogType.Warning)
|
|
warnings.Add(BuildIssue.From(m.content));
|
|
}
|
|
}
|
|
|
|
steps.Add(entry);
|
|
}
|
|
return steps;
|
|
}
|
|
|
|
static string MapLogType(LogType type)
|
|
{
|
|
switch (type)
|
|
{
|
|
case LogType.Warning: return "Warning";
|
|
case LogType.Error:
|
|
case LogType.Assert:
|
|
case LogType.Exception: return "Error";
|
|
default: return "Log";
|
|
}
|
|
}
|
|
|
|
// ---- Helpers ---------------------------------------------------------------------------
|
|
|
|
/// <summary>Case-insensitive parse of a <see cref="BuildTarget"/> enum name.</summary>
|
|
static bool TryParseTarget(string name, out BuildTarget target)
|
|
{
|
|
try
|
|
{
|
|
target = (BuildTarget)Enum.Parse(typeof(BuildTarget), name.Trim(), ignoreCase: true);
|
|
return Enum.IsDefined(typeof(BuildTarget), target);
|
|
}
|
|
catch
|
|
{
|
|
target = EditorUserBuildSettings.activeBuildTarget;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static object Invalid(string field, string message) => new { field, message };
|
|
|
|
static string NewBuildId() => "build_" + Guid.NewGuid().ToString("N").Substring(0, 12);
|
|
|
|
static string ResolveOutput(string output, BuildTarget target)
|
|
{
|
|
var projectRoot = ProjectPaths.ProjectRoot;
|
|
|
|
if (!string.IsNullOrWhiteSpace(output))
|
|
return Path.IsPathRooted(output) ? output : Path.GetFullPath(Path.Combine(projectRoot, output));
|
|
|
|
var product = string.IsNullOrEmpty(PlayerSettings.productName) ? "Player" : PlayerSettings.productName;
|
|
return Path.Combine(projectRoot, "Builds", target.ToString(), product + ExtensionFor(target));
|
|
}
|
|
|
|
/// <summary>True if some ancestor directory of <paramref name="path"/> already exists — a cheap,
|
|
/// non-mutating check that the build output folder is creatable.</summary>
|
|
static bool HasExistingAncestor(string path)
|
|
{
|
|
var dir = Path.GetDirectoryName(path);
|
|
while (!string.IsNullOrEmpty(dir))
|
|
{
|
|
if (Directory.Exists(dir))
|
|
return true;
|
|
dir = Path.GetDirectoryName(dir);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static string ExtensionFor(BuildTarget target)
|
|
{
|
|
switch (target)
|
|
{
|
|
case BuildTarget.StandaloneWindows:
|
|
case BuildTarget.StandaloneWindows64:
|
|
return ".exe";
|
|
case BuildTarget.StandaloneOSX:
|
|
return ".app";
|
|
case BuildTarget.Android:
|
|
return ".apk";
|
|
default:
|
|
return string.Empty; // Linux standalone, dedicated server, etc.
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Best-effort: activate a Build Profile by name via reflection so the assembly still compiles on
|
|
/// Unity versions without the Build Profile API. Any failure is logged and ignored — the build
|
|
/// proceeds with the current configuration.
|
|
/// </summary>
|
|
static void TryActivateBuildProfile(string profileName)
|
|
{
|
|
try
|
|
{
|
|
var info = BuildProfiles.FindByName(profileName);
|
|
if (info == null)
|
|
{
|
|
Debug.LogWarning($"[pipeline] build: profile '{profileName}' not found; building with current settings.");
|
|
return;
|
|
}
|
|
if (!BuildProfiles.TrySetActive(info))
|
|
Debug.LogWarning($"[pipeline] build: could not activate profile '{profileName}'; building with current settings.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogWarning($"[pipeline] build: profile activation failed ({ex.Message}); building with current settings.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run <paramref name="fn"/> on the Unity main thread. Off-thread (the HTTP path) it marshals
|
|
/// through the live server's dispatcher; on the main thread (a direct in-process/test call, or no
|
|
/// server running) it runs inline. Mirrors <see cref="PackageManager.PackageManagerCommand"/>.
|
|
/// </summary>
|
|
static T RunOnMain<T>(Func<T> fn)
|
|
{
|
|
var dispatcher = PipelineServerStartup.Server?.Dispatcher;
|
|
if (dispatcher != null && dispatcher.IsInitialized && !dispatcher.IsMainThread())
|
|
return dispatcher.Invoke(fn);
|
|
return fn();
|
|
}
|
|
|
|
static void WriteStatusJson(object status)
|
|
{
|
|
try
|
|
{
|
|
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError($"[Build] Failed to write status file: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
}
|