Skip to content
This repository was archived by the owner on Aug 14, 2026. It is now read-only.

Commit 68bbf99

Browse files
frostebiteclaude
andauthored
feat(logs): collect Unity diagnostic logs + live-tail (refs unity-builder#740) (#23)
* feat(logs): collect Unity diagnostic logs + live-tail (issue game-ci/unity-builder#740) Adds an opt-in, comprehensive Unity log/diagnostic collector and a live log tailer to the orchestrator plugin lifecycle. Driven by Unity support requests for files that live outside the project workspace and are otherwise invisible to the artifact upload step. Highlights - UnityLogCollectorService walks a per-platform path registry covering Editor.log, Unity.Licensing.Client.log, Unity.Entitlements.Audit.log, services-config.json, Unity Hub info/error logs, editor crash dir, build report, bee_backend.log, Player.log, test results, IL2CPP output, ProjectVersion.txt, package manifest, macOS crash reports, and a Windows Event Log slice. - Linux / macOS / Windows path templates with env-var expansion (HOME, USERPROFILE, LOCALAPPDATA, APPDATA, PROGRAMDATA, PROJECT, WORKSPACE), workspace-relative categories, and glob-aware path walking for Player.log / crash reports / wildcard ulf paths. - Sensitive categories (license-file) excluded by default; opt-in via unityLogsIncludeSensitive. - UnityLogTailService streams new lines from configured files to GHA log via core.info, with a per-file byte cap and recovery from file rotation. Defaults to <project>/Builds/Logs/Editor.log. - buildContainerCopyScript() emits a shell snippet that copies Linux container-internal logs to a workspace-relative dir before the container exits, so host-side artifact upload picks them up. - Wired into beforeLocalBuild (start tail) and afterLocalBuild (stop tail, run collector, register unity-logs output type). - 9 vitest cases covering happy path, missing categories, sensitive gating, workspace-relative collection, category parsing, and live tail behaviour. - Action inputs: collectUnityLogs, collectUnityLogsOnSuccess, unityLogCategories, unityLogsIncludeSensitive, unityLogsOutputDir, streamUnityLogs, streamUnityLogPaths. Refs: game-ci/unity-builder#740 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(logs): convenient Logs facade + index re-exports Adds a top-level Logs facade so library consumers can do: import { Logs } from '@game-ci/orchestrator'; await Logs.collect({ outputDir: './bundle' }); const tail = Logs.tail({ files: ['/path/Editor.log'] }); tail.stop(); Logs.categories(); // introspect available log categories without reaching into model/orchestrator/services/output/. The underlying UnityLogCollectorService and UnityLogTailService are now also re-exported from src/index.ts alongside the path registry types, so users who need fuller control still have direct access. 4 vitest cases covering collect/tail/category-introspection/parse. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d73634e commit 68bbf99

9 files changed

Lines changed: 1444 additions & 0 deletions

action.yml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,60 @@ inputs:
386386
default: 'true'
387387
description: 'Add -disable-assembly-updater to batchmode launches to prevent ApiUpdater from destabilizing CI sessions.'
388388

389+
# ─── Unity diagnostic log collection ──────────────────────────────
390+
collectUnityLogs:
391+
required: false
392+
default: 'false'
393+
description: >
394+
Collect Unity-internal logs (Editor.log, Unity.Licensing.Client.log,
395+
Unity.Entitlements.Audit.log, services-config.json, Unity Hub logs,
396+
crash dumps, build report, bee_backend.log, IL2CPP output, project
397+
version, package manifest, etc.) into <workspace>/Logs/UnityDiagnostics/
398+
after the build so they can be uploaded as artifacts and shared with
399+
Unity support. See also: collectUnityLogsOnSuccess, unityLogCategories.
400+
collectUnityLogsOnSuccess:
401+
required: false
402+
default: 'true'
403+
description: >
404+
When false, only collect Unity logs on non-zero exit codes. Default
405+
true so successful builds also publish diagnostic artifacts (useful
406+
for licensing audits and Unity support tickets).
407+
unityLogCategories:
408+
required: false
409+
default: ''
410+
description: >
411+
Comma-separated subset of categories to collect (default = all
412+
non-sensitive). Categories: editor-log, editor-prev-log,
413+
licensing-client, entitlements-audit, services-config, unity-hub-info,
414+
unity-hub-error, editor-crash, build-report, bee-backend, player-log,
415+
test-results, il2cpp-output, project-version, package-manifest,
416+
macos-crash-report, windows-event-log.
417+
unityLogsIncludeSensitive:
418+
required: false
419+
default: 'false'
420+
description: >
421+
Include sensitive categories (e.g. license-file). NEVER enable in
422+
public CI artifacts. Off by default.
423+
unityLogsOutputDir:
424+
required: false
425+
default: ''
426+
description: >
427+
Override the output directory for collected Unity logs. Default is
428+
<workspace>/Logs/UnityDiagnostics/.
429+
streamUnityLogs:
430+
required: false
431+
default: 'false'
432+
description: >
433+
Live-tail Unity log files during the build and forward each new line
434+
to the GitHub Actions log. Useful for long builds where you want to
435+
see compile/import progress without waiting for the build to finish.
436+
streamUnityLogPaths:
437+
required: false
438+
default: ''
439+
description: >
440+
Comma-separated list of files to live-tail. Defaults to the standard
441+
<project>/Builds/Logs/Editor.log path that unity-builder configures.
442+
389443
# ─── Build archive ─────────────────────────────────────────────────
390444
buildArchiveEnabled:
391445
required: false
@@ -795,6 +849,13 @@ runs:
795849
INPUT_ACCELERATORMODE: ${{ inputs.acceleratorMode }}
796850
INPUT_TESTRESULTCLEANUP: ${{ inputs.testResultCleanup }}
797851
INPUT_DISABLEASSEMBLYUPDATER: ${{ inputs.disableAssemblyUpdater }}
852+
INPUT_COLLECTUNITYLOGS: ${{ inputs.collectUnityLogs }}
853+
INPUT_COLLECTUNITYLOGSONSUCCESS: ${{ inputs.collectUnityLogsOnSuccess }}
854+
INPUT_UNITYLOGCATEGORIES: ${{ inputs.unityLogCategories }}
855+
INPUT_UNITYLOGSINCLUDESENSITIVE: ${{ inputs.unityLogsIncludeSensitive }}
856+
INPUT_UNITYLOGSOUTPUTDIR: ${{ inputs.unityLogsOutputDir }}
857+
INPUT_STREAMUNITYLOGS: ${{ inputs.streamUnityLogs }}
858+
INPUT_STREAMUNITYLOGPATHS: ${{ inputs.streamUnityLogPaths }}
798859
INPUT_BUILDARCHIVEENABLED: ${{ inputs.buildArchiveEnabled }}
799860
INPUT_BUILDARCHIVEPATH: ${{ inputs.buildArchivePath }}
800861
INPUT_BUILDARCHIVERETENTION: ${{ inputs.buildArchiveRetention }}

src/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,30 @@ export { HotRunnerService } from './model/orchestrator/services/hot-runner';
6565
export { OutputService } from './model/orchestrator/services/output/output-service';
6666
export { OutputTypeRegistry } from './model/orchestrator/services/output/output-type-registry';
6767
export { ArtifactUploadHandler } from './model/orchestrator/services/output/artifact-upload-handler';
68+
export { Logs } from './model/orchestrator/services/output/logs-facade';
69+
export type {
70+
LogsCollectOptions,
71+
LogsTailHandle,
72+
} from './model/orchestrator/services/output/logs-facade';
73+
export { UnityLogCollectorService } from './model/orchestrator/services/output/unity-log-collector-service';
74+
export type {
75+
UnityLogCollectionOptions,
76+
UnityLogCollectionResult,
77+
UnityLogCollectionResultItem,
78+
} from './model/orchestrator/services/output/unity-log-collector-service';
79+
export { UnityLogTailService } from './model/orchestrator/services/output/unity-log-tail-service';
80+
export type { UnityLogTailOptions } from './model/orchestrator/services/output/unity-log-tail-service';
81+
export {
82+
UNITY_LOG_PATHS,
83+
getUnityLogPath,
84+
listAllUnityLogCategories,
85+
listSafeUnityLogCategories,
86+
} from './model/orchestrator/services/output/unity-log-paths';
87+
export type {
88+
UnityLogCategory,
89+
UnityLogPathDefinition,
90+
UnityLogPlatform,
91+
} from './model/orchestrator/services/output/unity-log-paths';
6892
export { IncrementalSyncService } from './model/orchestrator/services/sync';
6993

7094
// Advanced services (lazy-loaded by unity-builder plugin interface)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { Logs } from './logs-facade';
6+
7+
function makeTempRoot(prefix: string): string {
8+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
9+
}
10+
11+
describe('Logs facade', () => {
12+
let workspace: string;
13+
let fakeHome: string;
14+
15+
beforeEach(() => {
16+
workspace = makeTempRoot('logs-facade-ws-');
17+
fakeHome = makeTempRoot('logs-facade-home-');
18+
});
19+
20+
afterEach(() => {
21+
fs.rmSync(workspace, { recursive: true, force: true });
22+
fs.rmSync(fakeHome, { recursive: true, force: true });
23+
});
24+
25+
it('collect() forwards to UnityLogCollectorService and returns a result', async () => {
26+
const unity3d = path.join(fakeHome, '.config', 'unity3d');
27+
fs.mkdirSync(path.join(unity3d, 'Unity'), { recursive: true });
28+
fs.writeFileSync(path.join(unity3d, 'Editor.log'), 'editor');
29+
30+
const result = await Logs.collect({
31+
workspace,
32+
projectPath: workspace,
33+
platform: 'linux',
34+
env: { HOME: fakeHome } as NodeJS.ProcessEnv,
35+
categories: ['editor-log'],
36+
});
37+
38+
expect(result.collected.map((c) => c.category)).toEqual(['editor-log']);
39+
expect(fs.existsSync(result.manifestPath)).toBe(true);
40+
});
41+
42+
it('tail() returns a stop handle', async () => {
43+
const filePath = path.join(workspace, 'Editor.log');
44+
fs.writeFileSync(filePath, 'first line\n');
45+
46+
const lines: string[] = [];
47+
const handle = Logs.tail({
48+
files: [filePath],
49+
onLine: (_file, line) => lines.push(line),
50+
pollIntervalMs: 50,
51+
prefixWithFilename: false,
52+
});
53+
54+
await new Promise((resolve) => setTimeout(resolve, 100));
55+
fs.appendFileSync(filePath, 'second\n');
56+
await new Promise((resolve) => setTimeout(resolve, 200));
57+
handle.stop();
58+
59+
expect(lines).toContain('first line');
60+
expect(lines).toContain('second');
61+
});
62+
63+
it('exposes category introspection helpers', () => {
64+
const all = Logs.allCategoryIds();
65+
const safe = Logs.safeCategoryIds();
66+
67+
expect(all).toContain('editor-log');
68+
expect(all).toContain('license-file');
69+
expect(safe).toContain('editor-log');
70+
expect(safe).not.toContain('license-file');
71+
72+
const categories = Logs.categories();
73+
expect(categories.length).toBe(all.length);
74+
const editor = categories.find((c) => c.category === 'editor-log');
75+
expect(editor?.description).toMatch(/Editor\.log/);
76+
});
77+
78+
it('parseCategories drops unknown entries', () => {
79+
expect(Logs.parseCategories('editor-log,bogus,licensing-client')).toEqual([
80+
'editor-log',
81+
'licensing-client',
82+
]);
83+
expect(Logs.parseCategories('all')).toBeUndefined();
84+
expect(Logs.parseCategories('')).toBeUndefined();
85+
});
86+
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import path from 'node:path';
2+
import {
3+
UnityLogCollectorService,
4+
UnityLogCollectionOptions,
5+
UnityLogCollectionResult,
6+
} from './unity-log-collector-service';
7+
import {
8+
UNITY_LOG_PATHS,
9+
UnityLogCategory,
10+
UnityLogPathDefinition,
11+
listAllUnityLogCategories,
12+
listSafeUnityLogCategories,
13+
} from './unity-log-paths';
14+
import { UnityLogTailService, UnityLogTailOptions } from './unity-log-tail-service';
15+
16+
export interface LogsCollectOptions extends Omit<
17+
UnityLogCollectionOptions,
18+
'workspace' | 'projectPath'
19+
> {
20+
/** Workspace root. Defaults to process.cwd(). */
21+
workspace?: string;
22+
/** Project path. Defaults to workspace. */
23+
projectPath?: string;
24+
}
25+
26+
export interface LogsTailHandle {
27+
/** Stop the tail and flush any buffered partial line. */
28+
stop(): void;
29+
}
30+
31+
/**
32+
* Convenience facade over the Unity log collection + tailing services.
33+
*
34+
* Lets library consumers do:
35+
*
36+
* import { Logs } from '@game-ci/orchestrator';
37+
*
38+
* const result = await Logs.collect({ outputDir: './unity-bundle' });
39+
* const tail = Logs.tail({ files: ['/path/to/Editor.log'] });
40+
* tail.stop();
41+
*
42+
* without reaching into `model/orchestrator/services/output/...`. The
43+
* underlying `UnityLogCollectorService` and `UnityLogTailService` remain
44+
* exported for users who need fuller control.
45+
*/
46+
export class Logs {
47+
/**
48+
* Collect Unity-internal logs into an artifact directory. Returns the
49+
* collection result (collected/missing items + manifest path + total bytes).
50+
*
51+
* Defaults workspace to `process.cwd()` and projectPath to workspace, so a
52+
* one-liner like `await Logs.collect()` works on a runner host.
53+
*/
54+
static async collect(options: LogsCollectOptions = {}): Promise<UnityLogCollectionResult> {
55+
const workspace = options.workspace || process.cwd();
56+
const projectPath = options.projectPath || workspace;
57+
58+
return UnityLogCollectorService.collect({
59+
...options,
60+
workspace,
61+
projectPath,
62+
});
63+
}
64+
65+
/**
66+
* Live-tail one or more Unity log files. Returns a handle with `stop()`.
67+
*
68+
* If `files` is omitted, tails the unity-builder default
69+
* `<projectPath>/Builds/Logs/Editor.log` and the orchestrator-collected
70+
* `<workspace>/Logs/UnityDiagnostics/editor-log/Editor.log` once it lands.
71+
*/
72+
static tail(
73+
options: Partial<UnityLogTailOptions> & {
74+
workspace?: string;
75+
projectPath?: string;
76+
} = {},
77+
): LogsTailHandle {
78+
const workspace = options.workspace || process.cwd();
79+
const projectPath = options.projectPath || workspace;
80+
const files =
81+
options.files && options.files.length > 0
82+
? options.files
83+
: [
84+
path.join(projectPath, 'Builds', 'Logs', 'Editor.log'),
85+
path.join(workspace, 'Logs', 'UnityDiagnostics', 'editor-log', 'Editor.log'),
86+
];
87+
88+
const service = new UnityLogTailService({
89+
...options,
90+
files,
91+
});
92+
service.start();
93+
return { stop: () => service.stop() };
94+
}
95+
96+
/**
97+
* List every known log category with its description and sensitivity flag.
98+
* Useful for building UIs/CLIs that surface what `Logs.collect()` will pick up.
99+
*/
100+
static categories(): UnityLogPathDefinition[] {
101+
return UNITY_LOG_PATHS.slice();
102+
}
103+
104+
/**
105+
* List safe (non-sensitive) category identifiers — the default set when
106+
* `categories` is omitted from `Logs.collect`.
107+
*/
108+
static safeCategoryIds(): UnityLogCategory[] {
109+
return listSafeUnityLogCategories();
110+
}
111+
112+
/**
113+
* List every category identifier including sensitive ones.
114+
*/
115+
static allCategoryIds(): UnityLogCategory[] {
116+
return listAllUnityLogCategories();
117+
}
118+
119+
/**
120+
* Parse a comma-separated category list string (e.g. from a CLI flag),
121+
* dropping unknown entries and treating "all"/empty as "default set".
122+
*/
123+
static parseCategories(input: string | undefined): UnityLogCategory[] | undefined {
124+
return UnityLogCollectorService.parseCategories(input);
125+
}
126+
}

0 commit comments

Comments
 (0)