feat(artifacts): complete generic artifact system with upload handlers, tests, and action integration (#798)

- Add ArtifactUploadHandler with support for github-artifacts, storage (rclone),
  and local copy upload targets, including large file chunking for GitHub Artifacts
- Add 44 unit tests covering OutputTypeRegistry, OutputService, and
  ArtifactUploadHandler (config parsing, upload coordination, file collection)
- Add 6 new action.yml inputs for artifact configuration
- Add artifactManifestPath action output
- Wire artifact collection and upload into index.ts post-build flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
frostebite
2026-03-05 11:51:43 +00:00
parent b3e1639029
commit aa2e05d468
8 changed files with 1631 additions and 1 deletions
Generated Vendored
+572
View File
@@ -34,10 +34,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
const core = __importStar(__nccwpck_require__(42186));
const node_path_1 = __importDefault(__nccwpck_require__(49411));
const model_1 = __nccwpck_require__(41359);
const cli_1 = __nccwpck_require__(55651);
const mac_builder_1 = __importDefault(__nccwpck_require__(39364));
const platform_setup_1 = __importDefault(__nccwpck_require__(64423));
const output_service_1 = __nccwpck_require__(18795);
const output_type_registry_1 = __nccwpck_require__(58012);
const artifact_upload_handler_1 = __nccwpck_require__(49063);
async function runMain() {
try {
if (cli_1.Cli.InitCliMode()) {
@@ -70,6 +74,44 @@ async function runMain() {
await model_1.Output.setBuildVersion(buildParameters.buildVersion);
await model_1.Output.setAndroidVersionCode(buildParameters.androidVersionCode);
await model_1.Output.setEngineExitCode(exitCode);
// Artifact collection and upload (runs on both success and failure)
try {
// Register custom output types if provided
if (buildParameters.artifactCustomTypes) {
try {
const customTypes = JSON.parse(buildParameters.artifactCustomTypes);
if (Array.isArray(customTypes)) {
for (const ct of customTypes) {
output_type_registry_1.OutputTypeRegistry.registerType({
name: ct.name,
defaultPath: ct.defaultPath || ct.pattern || `./${ct.name}/`,
description: ct.description || `Custom output type: ${ct.name}`,
builtIn: false,
});
}
}
}
catch (parseError) {
core.warning(`Failed to parse artifactCustomTypes: ${parseError.message}`);
}
}
// Collect outputs and generate manifest
const manifestPath = node_path_1.default.join(buildParameters.projectPath, 'output-manifest.json');
const manifest = await output_service_1.OutputService.collectOutputs(buildParameters.projectPath, buildParameters.buildGuid, buildParameters.artifactOutputTypes, manifestPath);
core.setOutput('artifactManifestPath', manifestPath);
// Upload artifacts
const uploadConfig = artifact_upload_handler_1.ArtifactUploadHandler.parseConfig(buildParameters.artifactUploadTarget, buildParameters.artifactUploadPath || undefined, buildParameters.artifactCompression, buildParameters.artifactRetentionDays);
const uploadResult = await artifact_upload_handler_1.ArtifactUploadHandler.uploadArtifacts(manifest, uploadConfig, buildParameters.projectPath);
if (!uploadResult.success) {
core.warning(`Artifact upload completed with errors: ${uploadResult.entries
.filter((e) => !e.success)
.map((e) => `${e.type}: ${e.error}`)
.join('; ')}`);
}
}
catch (artifactError) {
core.warning(`Artifact collection/upload failed: ${artifactError.message}`);
}
if (exitCode !== 0) {
core.setFailed(`Build failed with exit code ${exitCode}`);
}
@@ -375,6 +417,12 @@ class BuildParameters {
cacheUnityInstallationOnMac: input_1.default.cacheUnityInstallationOnMac,
unityHubVersionOnMac: input_1.default.unityHubVersionOnMac,
dockerWorkspacePath: input_1.default.dockerWorkspacePath,
artifactOutputTypes: input_1.default.artifactOutputTypes,
artifactUploadTarget: input_1.default.artifactUploadTarget,
artifactUploadPath: input_1.default.artifactUploadPath,
artifactCompression: input_1.default.artifactCompression,
artifactRetentionDays: input_1.default.artifactRetentionDays,
artifactCustomTypes: input_1.default.artifactCustomTypes,
};
}
static parseBuildFile(filename, platform, androidExportType) {
@@ -1823,6 +1871,24 @@ class Input {
static get containerRegistryImageVersion() {
return Input.getInput('containerRegistryImageVersion') ?? '3';
}
static get artifactOutputTypes() {
return Input.getInput('artifactOutputTypes') ?? 'build,logs,test-results';
}
static get artifactUploadTarget() {
return Input.getInput('artifactUploadTarget') ?? 'github-artifacts';
}
static get artifactUploadPath() {
return Input.getInput('artifactUploadPath') ?? '';
}
static get artifactCompression() {
return Input.getInput('artifactCompression') ?? 'gzip';
}
static get artifactRetentionDays() {
return Input.getInput('artifactRetentionDays') ?? '30';
}
static get artifactCustomTypes() {
return Input.getInput('artifactCustomTypes') ?? '';
}
static get skipActivation() {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
@@ -9632,6 +9698,504 @@ class ContainerHookService {
exports.ContainerHookService = ContainerHookService;
/***/ }),
/***/ 49063:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.ArtifactUploadHandler = void 0;
const node_fs_1 = __importDefault(__nccwpck_require__(87561));
const node_path_1 = __importDefault(__nccwpck_require__(49411));
const exec_1 = __nccwpck_require__(71514);
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
/**
* GitHub Artifacts size limit per artifact (10 GB).
* Files larger than this must be split.
*/
const GITHUB_ARTIFACT_SIZE_LIMIT = 10 * 1024 * 1024 * 1024;
/**
* Handles uploading build artifacts to various targets.
*/
class ArtifactUploadHandler {
/**
* Upload artifacts described by a manifest to the configured target.
*/
static async uploadArtifacts(manifest, config, projectPath) {
const startTime = Date.now();
const result = {
success: true,
entries: [],
totalBytes: 0,
durationMs: 0,
};
if (config.target === 'none') {
orchestrator_logger_1.default.log('[ArtifactUpload] Upload target is "none", skipping upload');
result.durationMs = Date.now() - startTime;
return result;
}
if (manifest.outputs.length === 0) {
orchestrator_logger_1.default.log('[ArtifactUpload] No outputs in manifest, nothing to upload');
result.durationMs = Date.now() - startTime;
return result;
}
orchestrator_logger_1.default.log(`[ArtifactUpload] Uploading ${manifest.outputs.length} output(s) to ${config.target}`);
for (const entry of manifest.outputs) {
const entryResult = await ArtifactUploadHandler.uploadEntry(entry, config, projectPath);
result.entries.push(entryResult);
result.totalBytes += entryResult.bytes;
if (!entryResult.success) {
result.success = false;
}
}
result.durationMs = Date.now() - startTime;
orchestrator_logger_1.default.log(`[ArtifactUpload] Upload complete: ${result.entries.filter((e) => e.success).length}/${result.entries.length} succeeded, ${result.totalBytes} bytes, ${result.durationMs}ms`);
return result;
}
/**
* Upload a single output entry.
*/
static async uploadEntry(entry, config, projectPath) {
const entryResult = {
type: entry.type,
path: entry.path,
success: false,
bytes: entry.size || 0,
};
const resolvedPath = node_path_1.default.resolve(projectPath, entry.path.replace('{platform}', process.env.BUILD_TARGET || 'Unknown'));
if (!node_fs_1.default.existsSync(resolvedPath)) {
entryResult.error = `Output path does not exist: ${resolvedPath}`;
orchestrator_logger_1.default.logWarning(`[ArtifactUpload] ${entryResult.error}`);
return entryResult;
}
try {
switch (config.target) {
case 'github-artifacts':
await ArtifactUploadHandler.uploadToGitHubArtifacts(entry, resolvedPath, config);
break;
case 'storage':
await ArtifactUploadHandler.uploadToStorage(entry, resolvedPath, config);
break;
case 'local':
await ArtifactUploadHandler.uploadToLocal(entry, resolvedPath, config);
break;
}
entryResult.success = true;
orchestrator_logger_1.default.log(`[ArtifactUpload] Uploaded '${entry.type}' (${entryResult.bytes} bytes) to ${config.target}`);
}
catch (error) {
entryResult.error = error.message || String(error);
orchestrator_logger_1.default.logWarning(`[ArtifactUpload] Failed to upload '${entry.type}': ${entryResult.error}`);
}
return entryResult;
}
/**
* Upload to GitHub Artifacts via @actions/artifact.
* Handles large file splitting if artifacts exceed the size limit.
*/
static async uploadToGitHubArtifacts(entry, resolvedPath, config) {
// Dynamically require @actions/artifact — it may not be available in all environments.
// Using a variable to prevent TypeScript from resolving the module at compile time.
let artifact;
try {
const artifactModule = '@actions/artifact';
// eslint-disable-next-line @typescript-eslint/no-require-imports
artifact = __nccwpck_require__(89346);
}
catch {
throw new Error('@actions/artifact package is not available. Install it to use github-artifacts upload target.');
}
const artifactClient = artifact.DefaultArtifactClient
? new artifact.DefaultArtifactClient()
: artifact.default
? new artifact.default()
: artifact;
const files = ArtifactUploadHandler.collectFiles(resolvedPath);
if (files.length === 0) {
orchestrator_logger_1.default.logWarning(`[ArtifactUpload] No files found at ${resolvedPath} for '${entry.type}'`);
return;
}
const totalSize = entry.size || 0;
const artifactName = `unity-output-${entry.type}`;
if (totalSize > GITHUB_ARTIFACT_SIZE_LIMIT) {
orchestrator_logger_1.default.log(`[ArtifactUpload] Output '${entry.type}' exceeds GitHub Artifacts size limit (${totalSize} > ${GITHUB_ARTIFACT_SIZE_LIMIT}), splitting into chunks`);
await ArtifactUploadHandler.uploadChunked(artifactClient, artifactName, files, resolvedPath, config);
}
else {
const rootDirectory = node_fs_1.default.statSync(resolvedPath).isDirectory() ? resolvedPath : node_path_1.default.dirname(resolvedPath);
if (typeof artifactClient.uploadArtifact === 'function') {
await artifactClient.uploadArtifact(artifactName, files, rootDirectory, {
retentionDays: config.retentionDays,
compressionLevel: config.compression === 'none' ? 0 : 6,
});
}
else {
throw new Error('@actions/artifact client does not have uploadArtifact method. Ensure the package version is compatible.');
}
}
}
/**
* Upload large artifacts in chunks to stay within GitHub size limits.
*/
static async uploadChunked(artifactClient, baseName, files, rootDirectory, config) {
const chunkSize = GITHUB_ARTIFACT_SIZE_LIMIT;
let currentChunkFiles = [];
let currentChunkSize = 0;
let chunkIndex = 0;
for (const filePath of files) {
const fileSize = node_fs_1.default.statSync(filePath).size;
if (currentChunkSize + fileSize > chunkSize && currentChunkFiles.length > 0) {
await ArtifactUploadHandler.uploadSingleChunk(artifactClient, `${baseName}-part${chunkIndex}`, currentChunkFiles, rootDirectory, config);
chunkIndex++;
currentChunkFiles = [];
currentChunkSize = 0;
}
currentChunkFiles.push(filePath);
currentChunkSize += fileSize;
}
if (currentChunkFiles.length > 0) {
await ArtifactUploadHandler.uploadSingleChunk(artifactClient, chunkIndex > 0 ? `${baseName}-part${chunkIndex}` : baseName, currentChunkFiles, rootDirectory, config);
}
}
static async uploadSingleChunk(artifactClient, name, files, rootDirectory, config) {
orchestrator_logger_1.default.log(`[ArtifactUpload] Uploading chunk '${name}' with ${files.length} file(s)`);
if (typeof artifactClient.uploadArtifact === 'function') {
await artifactClient.uploadArtifact(name, files, rootDirectory, {
retentionDays: config.retentionDays,
compressionLevel: config.compression === 'none' ? 0 : 6,
});
}
}
/**
* Upload to remote storage via rclone.
*/
static async uploadToStorage(entry, resolvedPath, config) {
if (!config.destination) {
throw new Error('Storage upload requires a destination URI in artifactUploadPath');
}
const destination = `${config.destination}/${entry.type}`;
orchestrator_logger_1.default.log(`[ArtifactUpload] Uploading '${entry.type}' to storage: ${destination}`);
const args = ['copy', resolvedPath, destination, '--progress'];
if (config.compression !== 'none') {
// rclone doesn't have built-in compression flags for copy;
// compression is typically handled by the remote configuration.
// Log as informational.
orchestrator_logger_1.default.log(`[ArtifactUpload] Note: compression '${config.compression}' is configured at the remote level for rclone`);
}
await (0, exec_1.exec)('rclone', args);
}
/**
* Upload to a local path (copy).
*/
static async uploadToLocal(entry, resolvedPath, config) {
if (!config.destination) {
throw new Error('Local upload requires a destination path in artifactUploadPath');
}
const destination = node_path_1.default.join(config.destination, entry.type);
node_fs_1.default.mkdirSync(destination, { recursive: true });
orchestrator_logger_1.default.log(`[ArtifactUpload] Copying '${entry.type}' to local path: ${destination}`);
ArtifactUploadHandler.copyRecursive(resolvedPath, destination);
}
/**
* Recursively copy files from source to destination.
*/
static copyRecursive(source, destination) {
const stat = node_fs_1.default.statSync(source);
if (stat.isDirectory()) {
node_fs_1.default.mkdirSync(destination, { recursive: true });
const entries = node_fs_1.default.readdirSync(source);
for (const entry of entries) {
ArtifactUploadHandler.copyRecursive(node_path_1.default.join(source, entry), node_path_1.default.join(destination, entry));
}
}
else {
node_fs_1.default.copyFileSync(source, destination);
}
}
/**
* Collect all files at a given path (recursively if directory).
*/
static collectFiles(targetPath) {
const stat = node_fs_1.default.statSync(targetPath);
if (!stat.isDirectory()) {
return [targetPath];
}
const files = [];
const entries = node_fs_1.default.readdirSync(targetPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = node_path_1.default.join(targetPath, entry.name);
if (entry.isDirectory()) {
files.push(...ArtifactUploadHandler.collectFiles(fullPath));
}
else {
files.push(fullPath);
}
}
return files;
}
/**
* Parse an ArtifactUploadConfig from action inputs.
*/
static parseConfig(target, destination, compression, retentionDays) {
const validTargets = ['github-artifacts', 'storage', 'local', 'none'];
const resolvedTarget = validTargets.includes(target)
? target
: 'github-artifacts';
const validCompressions = ['none', 'gzip', 'lz4'];
const resolvedCompression = validCompressions.includes(compression)
? compression
: 'gzip';
const parsedRetention = Number.parseInt(retentionDays, 10);
const resolvedRetention = Number.isNaN(parsedRetention) || parsedRetention <= 0 ? 30 : parsedRetention;
return {
target: resolvedTarget,
destination: destination || undefined,
compression: resolvedCompression,
retentionDays: resolvedRetention,
};
}
}
exports.ArtifactUploadHandler = ArtifactUploadHandler;
/***/ }),
/***/ 18795:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.OutputService = void 0;
const node_fs_1 = __importDefault(__nccwpck_require__(87561));
const node_path_1 = __importDefault(__nccwpck_require__(49411));
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
const output_type_registry_1 = __nccwpck_require__(58012);
/**
* 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.
*/
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, buildGuid, outputTypesInput, manifestPath) {
const types = output_type_registry_1.OutputTypeRegistry.parseOutputTypes(outputTypesInput);
const manifest = {
buildGuid,
timestamp: new Date().toISOString(),
outputs: [],
};
if (types.length === 0) {
orchestrator_logger_1.default.log('[Output] No output types declared, skipping collection');
return manifest;
}
orchestrator_logger_1.default.log(`[Output] Collecting ${types.length} output type(s): ${types.map((t) => t.name).join(', ')}`);
for (const typeDef of types) {
const outputPath = node_path_1.default.join(projectPath, typeDef.defaultPath.replace('{platform}', process.env.BUILD_TARGET || 'Unknown'));
if (!node_fs_1.default.existsSync(outputPath)) {
orchestrator_logger_1.default.log(`[Output] No output found for '${typeDef.name}' at ${outputPath}`);
continue;
}
const entry = {
type: typeDef.name,
path: typeDef.defaultPath,
};
// Collect file listing for directory outputs
try {
const stat = node_fs_1.default.statSync(outputPath);
if (stat.isDirectory()) {
entry.files = node_fs_1.default.readdirSync(outputPath);
entry.size = OutputService.getDirectorySize(outputPath);
}
else {
entry.size = stat.size;
}
}
catch {
orchestrator_logger_1.default.logWarning(`[Output] Failed to stat output '${typeDef.name}' at ${outputPath}`);
}
manifest.outputs.push(entry);
orchestrator_logger_1.default.log(`[Output] Collected '${typeDef.name}': ${entry.files?.length || 1} file(s), ${entry.size || 0} bytes`);
}
// Write manifest to disk
if (manifestPath) {
try {
const manifestDir = node_path_1.default.dirname(manifestPath);
node_fs_1.default.mkdirSync(manifestDir, { recursive: true });
node_fs_1.default.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
orchestrator_logger_1.default.log(`[Output] Manifest written to ${manifestPath}`);
}
catch (error) {
orchestrator_logger_1.default.logWarning(`[Output] Failed to write manifest: ${error.message}`);
}
}
return manifest;
}
/**
* Calculate total size of a directory recursively.
*/
static getDirectorySize(dirPath) {
let totalSize = 0;
try {
const entries = node_fs_1.default.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = node_path_1.default.join(dirPath, entry.name);
if (entry.isDirectory()) {
totalSize += OutputService.getDirectorySize(fullPath);
}
else {
totalSize += node_fs_1.default.statSync(fullPath).size;
}
}
}
catch {
// Ignore errors in size calculation
}
return totalSize;
}
}
exports.OutputService = OutputService;
/***/ }),
/***/ 58012:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.OutputTypeRegistry = void 0;
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
class OutputTypeRegistry {
/**
* Get a type definition by name. Checks custom types first, then built-in.
*/
static getType(name) {
return OutputTypeRegistry.customTypes[name] || OutputTypeRegistry.builtInTypes[name];
}
/**
* Get all registered types (built-in + custom).
*/
static getAllTypes() {
return [
...Object.values(OutputTypeRegistry.builtInTypes),
...Object.values(OutputTypeRegistry.customTypes),
];
}
/**
* Register a custom output type.
*/
static registerType(definition) {
if (OutputTypeRegistry.builtInTypes[definition.name]) {
orchestrator_logger_1.default.logWarning(`[OutputTypes] Cannot override built-in type '${definition.name}'`);
return;
}
OutputTypeRegistry.customTypes[definition.name] = { ...definition, builtIn: false };
orchestrator_logger_1.default.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) {
if (!outputTypesInput) {
return [];
}
const names = outputTypesInput.split(',').map((s) => s.trim()).filter(Boolean);
const types = [];
for (const name of names) {
const typeDef = OutputTypeRegistry.getType(name);
if (typeDef) {
types.push(typeDef);
}
else {
orchestrator_logger_1.default.logWarning(`[OutputTypes] Unknown output type '${name}', skipping`);
}
}
return types;
}
/**
* Reset custom types (for testing).
*/
static resetCustomTypes() {
OutputTypeRegistry.customTypes = {};
}
}
exports.OutputTypeRegistry = OutputTypeRegistry;
OutputTypeRegistry.builtInTypes = {
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,
},
};
OutputTypeRegistry.customTypes = {};
/***/ }),
/***/ 23451:
@@ -358535,6 +359099,14 @@ try {
} catch (er) {}
/***/ }),
/***/ 89346:
/***/ ((module) => {
module.exports = eval("require")("@actions/artifact");
/***/ }),
/***/ 71269:
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long