mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-10 07:53:52 -07:00
feat(orchestrator): generic artifact system — output types, manifests, and collection service
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export { OutputManifest, OutputEntry } from './output-manifest';
|
||||
export { OutputTypeRegistry, OutputTypeDefinition } from './output-type-registry';
|
||||
export { OutputService } from './output-service';
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Structured build output manifest.
|
||||
* Describes all artifacts produced by a build with type, path, size, hash, and metadata.
|
||||
*/
|
||||
|
||||
export interface OutputEntry {
|
||||
/** Output type identifier (e.g., 'build', 'test-results', 'images') */
|
||||
type: string;
|
||||
|
||||
/** Relative path to the output */
|
||||
path: string;
|
||||
|
||||
/** Output format (e.g., 'nunit3', 'junit', 'json') */
|
||||
format?: string;
|
||||
|
||||
/** File size in bytes */
|
||||
size?: number;
|
||||
|
||||
/** Content hash (e.g., 'sha256:abc...') */
|
||||
hash?: string;
|
||||
|
||||
/** Individual files within the output path */
|
||||
files?: string[];
|
||||
|
||||
/** Type-specific summary (e.g., test counts, build size) */
|
||||
summary?: Record<string, unknown>;
|
||||
|
||||
/** Arbitrary metadata */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface OutputManifest {
|
||||
/** Unique build identifier */
|
||||
buildGuid: string;
|
||||
|
||||
/** ISO 8601 timestamp */
|
||||
timestamp: string;
|
||||
|
||||
/** All outputs produced by this build */
|
||||
outputs: OutputEntry[];
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||
import { OutputManifest, OutputEntry } from './output-manifest';
|
||||
import { OutputTypeRegistry } from './output-type-registry';
|
||||
|
||||
/**
|
||||
* Service for collecting, manifesting, and managing build outputs.
|
||||
*
|
||||
* After a build completes, this service scans declared output paths,
|
||||
* generates a structured manifest, and prepares outputs for post-processing.
|
||||
*/
|
||||
export class OutputService {
|
||||
/**
|
||||
* Collect outputs from the workspace and generate a manifest.
|
||||
*
|
||||
* @param projectPath - Path to the Unity project root
|
||||
* @param buildGuid - Unique build identifier
|
||||
* @param outputTypesInput - Comma-separated output type names
|
||||
* @param manifestPath - Where to write the manifest JSON (optional)
|
||||
* @returns The generated output manifest
|
||||
*/
|
||||
static async collectOutputs(
|
||||
projectPath: string,
|
||||
buildGuid: string,
|
||||
outputTypesInput: string,
|
||||
manifestPath?: string,
|
||||
): Promise<OutputManifest> {
|
||||
const types = OutputTypeRegistry.parseOutputTypes(outputTypesInput);
|
||||
const manifest: OutputManifest = {
|
||||
buildGuid,
|
||||
timestamp: new Date().toISOString(),
|
||||
outputs: [],
|
||||
};
|
||||
|
||||
if (types.length === 0) {
|
||||
OrchestratorLogger.log('[Output] No output types declared, skipping collection');
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
OrchestratorLogger.log(`[Output] Collecting ${types.length} output type(s): ${types.map((t) => t.name).join(', ')}`);
|
||||
|
||||
for (const typeDef of types) {
|
||||
const outputPath = path.join(projectPath, typeDef.defaultPath.replace('{platform}', process.env.BUILD_TARGET || 'Unknown'));
|
||||
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
OrchestratorLogger.log(`[Output] No output found for '${typeDef.name}' at ${outputPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry: OutputEntry = {
|
||||
type: typeDef.name,
|
||||
path: typeDef.defaultPath,
|
||||
};
|
||||
|
||||
// Collect file listing for directory outputs
|
||||
try {
|
||||
const stat = fs.statSync(outputPath);
|
||||
if (stat.isDirectory()) {
|
||||
entry.files = fs.readdirSync(outputPath);
|
||||
entry.size = OutputService.getDirectorySize(outputPath);
|
||||
} else {
|
||||
entry.size = stat.size;
|
||||
}
|
||||
} catch {
|
||||
OrchestratorLogger.logWarning(`[Output] Failed to stat output '${typeDef.name}' at ${outputPath}`);
|
||||
}
|
||||
|
||||
manifest.outputs.push(entry);
|
||||
OrchestratorLogger.log(`[Output] Collected '${typeDef.name}': ${entry.files?.length || 1} file(s), ${entry.size || 0} bytes`);
|
||||
}
|
||||
|
||||
// Write manifest to disk
|
||||
if (manifestPath) {
|
||||
try {
|
||||
const manifestDir = path.dirname(manifestPath);
|
||||
fs.mkdirSync(manifestDir, { recursive: true });
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
||||
OrchestratorLogger.log(`[Output] Manifest written to ${manifestPath}`);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[Output] Failed to write manifest: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total size of a directory recursively.
|
||||
*/
|
||||
private static getDirectorySize(dirPath: string): number {
|
||||
let totalSize = 0;
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
totalSize += OutputService.getDirectorySize(fullPath);
|
||||
} else {
|
||||
totalSize += fs.statSync(fullPath).size;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors in size calculation
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||
|
||||
/**
|
||||
* Registry of known output types with default paths and processing hints.
|
||||
*/
|
||||
|
||||
export interface OutputTypeDefinition {
|
||||
/** Type identifier */
|
||||
name: string;
|
||||
|
||||
/** Default output path (relative to project root) */
|
||||
defaultPath: string;
|
||||
|
||||
/** Human-readable description */
|
||||
description: string;
|
||||
|
||||
/** Whether this type is built-in or user-registered */
|
||||
builtIn: boolean;
|
||||
}
|
||||
|
||||
export class OutputTypeRegistry {
|
||||
private static readonly builtInTypes: Record<string, OutputTypeDefinition> = {
|
||||
build: {
|
||||
name: 'build',
|
||||
defaultPath: './Builds/{platform}/',
|
||||
description: 'Standard game build artifact',
|
||||
builtIn: true,
|
||||
},
|
||||
'test-results': {
|
||||
name: 'test-results',
|
||||
defaultPath: './TestResults/',
|
||||
description: 'NUnit/JUnit XML test results',
|
||||
builtIn: true,
|
||||
},
|
||||
'server-build': {
|
||||
name: 'server-build',
|
||||
defaultPath: './Builds/{platform}-server/',
|
||||
description: 'Dedicated server build artifact',
|
||||
builtIn: true,
|
||||
},
|
||||
'data-export': {
|
||||
name: 'data-export',
|
||||
defaultPath: './Exports/',
|
||||
description: 'Exported data files (CSV, JSON, binary)',
|
||||
builtIn: true,
|
||||
},
|
||||
images: {
|
||||
name: 'images',
|
||||
defaultPath: './Captures/',
|
||||
description: 'Screenshots, render captures, atlas previews',
|
||||
builtIn: true,
|
||||
},
|
||||
logs: {
|
||||
name: 'logs',
|
||||
defaultPath: './Logs/',
|
||||
description: 'Structured build and test logs',
|
||||
builtIn: true,
|
||||
},
|
||||
metrics: {
|
||||
name: 'metrics',
|
||||
defaultPath: './Metrics/',
|
||||
description: 'Build performance metrics and asset statistics',
|
||||
builtIn: true,
|
||||
},
|
||||
coverage: {
|
||||
name: 'coverage',
|
||||
defaultPath: './Coverage/',
|
||||
description: 'Code coverage reports',
|
||||
builtIn: true,
|
||||
},
|
||||
};
|
||||
|
||||
private static customTypes: Record<string, OutputTypeDefinition> = {};
|
||||
|
||||
/**
|
||||
* Get a type definition by name. Checks custom types first, then built-in.
|
||||
*/
|
||||
static getType(name: string): OutputTypeDefinition | undefined {
|
||||
return OutputTypeRegistry.customTypes[name] || OutputTypeRegistry.builtInTypes[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered types (built-in + custom).
|
||||
*/
|
||||
static getAllTypes(): OutputTypeDefinition[] {
|
||||
return [
|
||||
...Object.values(OutputTypeRegistry.builtInTypes),
|
||||
...Object.values(OutputTypeRegistry.customTypes),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom output type.
|
||||
*/
|
||||
static registerType(definition: OutputTypeDefinition): void {
|
||||
if (OutputTypeRegistry.builtInTypes[definition.name]) {
|
||||
OrchestratorLogger.logWarning(
|
||||
`[OutputTypes] Cannot override built-in type '${definition.name}'`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OutputTypeRegistry.customTypes[definition.name] = { ...definition, builtIn: false };
|
||||
OrchestratorLogger.log(`[OutputTypes] Registered custom type '${definition.name}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a comma-separated output types string into type definitions.
|
||||
* Unknown types are logged as warnings and skipped.
|
||||
*/
|
||||
static parseOutputTypes(outputTypesInput: string): OutputTypeDefinition[] {
|
||||
if (!outputTypesInput) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const names = outputTypesInput.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const types: OutputTypeDefinition[] = [];
|
||||
|
||||
for (const name of names) {
|
||||
const typeDef = OutputTypeRegistry.getType(name);
|
||||
if (typeDef) {
|
||||
types.push(typeDef);
|
||||
} else {
|
||||
OrchestratorLogger.logWarning(`[OutputTypes] Unknown output type '${name}', skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
return types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset custom types (for testing).
|
||||
*/
|
||||
static resetCustomTypes(): void {
|
||||
OutputTypeRegistry.customTypes = {};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user