diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e5d89cb..924d58d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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" diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index 7189e69..51ab935 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -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 @@ -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 diff --git a/README.md b/README.md index b4d471b..7e7b391 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ npm install -g @rage-against-the-pixel/unity-cli In general, the command structure is: ```bash -unity-cli [command] [options] +unity-cli [command] {options} ``` 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. diff --git a/package-lock.json b/package-lock.json index 281a4bd..4df758e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rage-against-the-pixel/unity-cli", - "version": "1.5.3", + "version": "1.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rage-against-the-pixel/unity-cli", - "version": "1.5.3", + "version": "1.5.4", "license": "MIT", "dependencies": { "@electron/asar": "^4.0.1", diff --git a/package.json b/package.json index 86dd47e..a28976b 100644 --- a/package.json +++ b/package.json @@ -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", @@ -68,4 +68,4 @@ "ts-node": "^10.9.2", "typescript": "^5.9.3" } -} +} \ No newline at end of file diff --git a/src/logging.ts b/src/logging.ts index 166143f..2599cd5 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -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]; @@ -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' }); + } + } + } + } } \ No newline at end of file diff --git a/src/unity-editor.ts b/src/unity-editor.ts index 6aa6d12..435fe66 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -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' && diff --git a/src/unity-hub.ts b/src/unity-hub.ts index 5c8510d..ba7cc67 100644 --- a/src/unity-hub.ts +++ b/src/unity-hub.ts @@ -108,9 +108,13 @@ export class UnityHub { try { exitCode = await new Promise((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'] }); @@ -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 { @@ -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); + } } } } @@ -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) { @@ -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); diff --git a/src/utilities.ts b/src/utilities.ts index 63c1c8e..5267d89 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -334,6 +334,8 @@ export interface LogTailResult { tailPromise: Promise; /** Function to signal that log tailing should end */ stopLogTail: () => void; + /** Collected telemetry objects parsed from lines beginning with '##utp:' */ + telemetry: any[]; } /** @@ -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 { 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; @@ -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}`); } } } @@ -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') { @@ -420,7 +447,7 @@ export function TailLogFile(logPath: string): LogTailResult { logEnded = true; } - return { tailPromise, stopLogTail }; + return { tailPromise, stopLogTail, telemetry }; } /** diff --git a/tests/mocks/electron-asar.js b/tests/mocks/electron-asar.js index 8a83d2b..ac4b15b 100644 --- a/tests/mocks/electron-asar.js +++ b/tests/mocks/electron-asar.js @@ -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: () => { }, };