Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 24.x
registry-url: "https://registry.npmjs.org"
Expand Down
7 changes: 4 additions & 3 deletions .github/workflows/unity-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ jobs:
RUN_BUILD: '' # Set to true if the build pipeline package can be installed and used
steps:
- name: Free Disk Space
if: ${{ matrix.os == 'ubuntu-latest' && matrix.unity-version == '6000.2' }}
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: endersonmenezes/free-disk-space@713d134e243b926eba4a5cce0cf608bfd1efb89a # v2.1.1
with:
remove_android: true
remove_dotnet: false
remove_tool_cache: false
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 24.x
- name: Setup unity-cli
Expand Down Expand Up @@ -140,9 +140,10 @@ jobs:
unity-cli return-license --license personal
- name: Upload Logs
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: ${{ github.run_id }}.${{ github.run_attempt }} ${{ matrix.os }} ${{ matrix.unity-version }} ${{ matrix.build-target }} logs
retention-days: 1
if-no-files-found: ignore
path: |
${{ github.workspace }}/**/*.log
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ npm install -g @rage-against-the-pixel/unity-cli
In general, the command structure is:

```bash
unity-cli [command] [options] <args...>
unity-cli [command] {options} <args...>
```

With options always using double dashes (`--option`) and arguments passed directly to Unity or Unity Hub commands as they normally would with single dashes (`-arg`). Each option typically has a short alias using a single dash (`-o`), except for commands where we pass through arguments, as those get confused by the command parser.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rage-against-the-pixel/unity-cli",
"version": "1.5.3",
"version": "1.5.4",
"description": "A command line utility for the Unity Game Engine.",
"author": "RageAgainstThePixel",
"license": "MIT",
Expand Down Expand Up @@ -68,4 +68,4 @@
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
}
85 changes: 85 additions & 0 deletions src/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,74 @@ export class Logger {
this.log(LogLevel.ERROR, message, optionalParams);
}

/**
* Annotates a file and line number in CI environments that support it.
* @param logLevel The level of the log.
* @param message The message to annotate.
* @param file The file to annotate.
* @param line The line number to annotate.
* @param endLine The end line number to annotate.
* @param column The column number to annotate.
* @param endColumn The end column number to annotate.
* @param title The title of the annotation.
*/
public annotate(logLevel: LogLevel, message: string, file?: string, line?: number, endLine?: number, column?: number, endColumn?: number, title?: string): void {
let annotation = '';

switch (this._ci) {
case 'GITHUB_ACTIONS': {
var level: string;
switch (logLevel) {
case LogLevel.CI:
case LogLevel.INFO:
case LogLevel.DEBUG: {
level = 'notice';
break;
}
case LogLevel.WARN: {
level = 'warning';
break;
}
case LogLevel.ERROR: {
level = 'error';
break;
}
}

let parts: string[] = [];

if (file !== undefined && file.length > 0) {
parts.push(`file=${file}`);
}

if (line !== undefined && line > 0) {
parts.push(`line=${line}`);
}

if (endLine !== undefined && endLine > 0) {
parts.push(`endLine=${endLine}`);
}

if (column !== undefined && column > 0) {
parts.push(`col=${column}`);
}

if (endColumn !== undefined && endColumn > 0) {
parts.push(`endColumn=${endColumn}`);
}

if (title !== undefined && title.length > 0) {
parts.push(`title=${title}`);
}

annotation = `::${level} ${parts.join(',')}::${message}`;
break;
}
}

process.stdout.write(`${annotation}\n`);
}

private shouldLog(level: LogLevel): boolean {
if (level === LogLevel.CI) { return true; }
const levelOrder = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR];
Expand Down Expand Up @@ -187,4 +255,21 @@ export class Logger {
}
}
}

public CI_appendWorkflowSummary(telemetry: any[]) {
switch (this._ci) {
case 'GITHUB_ACTIONS': {
const githubSummary = process.env.GITHUB_STEP_SUMMARY;

if (githubSummary) {
let table = `| Key | Value |\n| --- | ----- |\n`;
telemetry.forEach(item => {
table += `| ${item.key} | ${item.value} |\n`;
});

fs.appendFileSync(githubSummary, table, { encoding: 'utf8' });
}
}
}
}
}
21 changes: 16 additions & 5 deletions src/unity-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,15 +272,26 @@ export class UnityEditor {
if (process.platform === 'linux' &&
!command.args.includes('-nographics')
) {
// On Linux, force Unity to run under Xvfb and provide a dummy audio driver
// to prevent FMOD from failing to initialize the output device when no
// actual audio device is present (common in CI/container environments).
const linuxEnv = {
...process.env,
DISPLAY: ':99',
UNITY_THISISABUILDMACHINE: '1',
// Tell various audio systems to use a dummy/out-of-process driver
SDL_AUDIODRIVER: process.env.SDL_AUDIODRIVER || 'dummy',
AUDIODRIVER: process.env.AUDIODRIVER || 'dummy',
AUDIODEV: process.env.AUDIODEV || 'null',
// For PulseAudio: point to an invalid socket to avoid connecting
PULSE_SERVER: process.env.PULSE_SERVER || '/tmp/invalid-pulse-socket'
};

unityProcess = spawn(
'xvfb-run',
[this.editorPath, ...command.args], {
stdio: ['ignore', 'ignore', 'ignore'],
env: {
...process.env,
DISPLAY: ':99',
UNITY_THISISABUILDMACHINE: '1'
}
env: linuxEnv
});
} else if (process.arch === 'arm64' &&
process.platform === 'darwin' &&
Expand Down
33 changes: 20 additions & 13 deletions src/unity-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,13 @@ export class UnityHub {
try {
exitCode = await new Promise<number>((resolve, reject) => {
let isSettled: boolean = false; // Has the promise been settled (resolved or rejected)?
let isHubTaskComplete: boolean = false; // Has the Unity Hub tasks completed successfully?
let isHubTaskCompleteSuccess: boolean = false; // Has the Unity Hub tasks completed successfully?
let isHubTaskCompleteFailed: boolean = false; // Has the Unity Hub tasks completed with failure?
let lineBuffer = ''; // Buffer for incomplete lines
const tasksCompleteMessage = 'All Tasks Completed Successfully.';
const tasksCompleteMessages: string[] = [
'All Tasks Completed Successfully.',
'Completed with errors.'
];
const child = spawn(executable, execArgs, {
stdio: ['ignore', 'pipe', 'pipe']
});
Expand Down Expand Up @@ -140,8 +144,9 @@ export class UnityHub {
lineBuffer = '';
}

if (lines.includes(tasksCompleteMessage)) {
isHubTaskComplete = true;
if (lines.some(line => tasksCompleteMessages.includes(line))) {
isHubTaskCompleteSuccess = lines.includes('All Tasks Completed Successfully.');
isHubTaskCompleteFailed = lines.includes('Completed with errors.');

if (child?.pid) {
try {
Expand All @@ -159,7 +164,13 @@ export class UnityHub {
} catch {
// Ignore, process may have already exited
} finally {
settle(0);
if (isHubTaskCompleteSuccess) {
settle(0);
} else if (isHubTaskCompleteFailed) {
settle(1);
} else {
settle(null);
}
}
}
}
Expand All @@ -186,8 +197,9 @@ export class UnityHub {
lineBuffer = '';
const outputLines = lines.filter(line => !ignoredLines.some(ignored => line.includes(ignored)));

if (outputLines.includes(tasksCompleteMessage)) {
isHubTaskComplete = true;
if (outputLines.some(line => tasksCompleteMessages.includes(line))) {
isHubTaskCompleteSuccess = outputLines.includes('All Tasks Completed Successfully.');
isHubTaskCompleteFailed = outputLines.includes('Completed with errors.');
}

for (const line of outputLines) {
Expand All @@ -210,12 +222,7 @@ export class UnityHub {
isSettled = true;
removeListeners();
flushOutput();

if (isHubTaskComplete) {
resolve(0);
} else {
resolve(code === null ? 0 : code);
}
resolve(code === null ? 0 : code);
}

child.stdout.on('data', processOutput);
Expand Down
47 changes: 37 additions & 10 deletions src/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ export interface LogTailResult {
tailPromise: Promise<void>;
/** Function to signal that log tailing should end */
stopLogTail: () => void;
/** Collected telemetry objects parsed from lines beginning with '##utp:' */
telemetry: any[];
}

/**
Expand All @@ -345,18 +347,13 @@ export function TailLogFile(logPath: string): LogTailResult {
let logEnded = false;
let lastSize = 0;
const logPollingInterval = 250;
const telemetry: any[] = [];

async function readNewLogContent(): Promise<void> {
try {
if (!fs.existsSync(logPath)) {
return;
}

if (!fs.existsSync(logPath)) { return; }
const stats = await fs.promises.stat(logPath);

if (stats.size < lastSize) {
lastSize = 0;
}
if (stats.size < lastSize) { lastSize = 0; }

if (stats.size > lastSize) {
const bytesToRead = stats.size - lastSize;
Expand All @@ -375,12 +372,41 @@ export function TailLogFile(logPath: string): LogTailResult {
if (bytesToRead > 0) {
const chunk = buffer.toString('utf8');

// Parse telemetry lines in this chunk (lines starting with '##utp:')
try {
process.stdout.write(chunk);
const lines = chunk.split(/\r?\n/);
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) { continue; }
if (line.startsWith('##utp:')) {
const jsonPart = line.substring('##utp:'.length).trim();
try {
const utp = JSON.parse(jsonPart);
telemetry.push(utp);

// annotate the log with the telemetry event
// ##utp:{"type":"Compiler","version":2,"phase":"Immediate","time":1762378495689,"processId":2256,"severity":"Error","message":"Assets\\_BurnerSphere\\Content\\Common Assets\\Lighting\\older\\ReflectionProbeBaker1.cs(75,13): error CS0103: The name 'AssetDatabase' does not exist in the current context","stacktrace":"","line":75,"file":"Assets\\_BurnerSphere\\Content\\Common Assets\\Lighting\\older\\ReflectionProbeBaker1.cs"}
if (utp.severity && utp.severity.toLowerCase() === 'error') {
const file = utp.file ? utp.file.replace(/\\/g, '/') : undefined;
const lineNum = utp.line ? utp.line : undefined;
const message = utp.message;
const stacktrace = utp.stacktrace ? `${utp.stacktrace}` : undefined;
if (!message.startsWith(`\n::error::\u001B[31m`)) { // indicates a duplicate annotation
Logger.instance.annotate(LogLevel.ERROR, stacktrace == undefined ? message : `${message}\n${stacktrace}`, file, lineNum);
}
}
} catch (error) {
logger.warn(`Failed to parse telemetry JSON: ${error} -- raw: ${jsonPart}`);
}
} else {
process.stdout.write(`${line}\n`);
}
}
} catch (error: any) {
if (error.code !== 'EPIPE') {
throw error;
}
logger.warn(`Error while parsing telemetry from log chunk: ${error}`);
}
}
}
Expand All @@ -402,6 +428,7 @@ export function TailLogFile(logPath: string): LogTailResult {
await readNewLogContent();

try {
// write a final newline to separate log output
process.stdout.write('\n');
} catch (error: any) {
if (error.code !== 'EPIPE') {
Expand All @@ -420,7 +447,7 @@ export function TailLogFile(logPath: string): LogTailResult {
logEnded = true;
}

return { tailPromise, stopLogTail };
return { tailPromise, stopLogTail, telemetry };
}

/**
Expand Down
11 changes: 6 additions & 5 deletions tests/mocks/electron-asar.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Minimal mock for @electron/asar used in tests
module.exports = {
extractFile: (asarPath, file) => {
if (file === 'package.json') {
return Buffer.from(JSON.stringify({ version: '1.0.0' }));
}
return Buffer.from('');
extractFile: (asarPath, file) => {
if (file === 'package.json') {
return Buffer.from(JSON.stringify({ version: '1.0.0' }));
}
return Buffer.from('');
},
uncacheAll: () => { },
};
Loading