mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-17 21:46:48 -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