Compare commits

..

5 Commits

Author SHA1 Message Date
frostebite
e3c87cc1cd fix: replace orchestrator-develop branch references with main
The orchestrator-develop branch no longer exists. Update all fallback
clone commands and test fixtures to use main instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:07:26 +00:00
frostebite
1171b7e7ae ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:33:44 +00:00
frostebite
7db70a712f style: fix prettier formatting and eslint errors on test files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:23:05 +00:00
frostebite
f4451060a7 ci(orchestrator): add fast unit test gate to integrity workflow
Adds a fast-fail unit test step at the top of orchestrator-integrity,
right after yarn install and before any infrastructure setup (k3d,
LocalStack). Runs 113 mock-based orchestrator tests in ~5 seconds.

If serialization, path computation, log parsing, or provider loading
is broken, the workflow fails immediately instead of spending 30+
minutes setting up LocalStack and k3d clusters.

Tests included: orchestrator-guid, orchestrator-folders,
task-parameter-serializer, follow-log-stream-service,
runner-availability-service, provider-url-parser, provider-loader,
provider-git-manager, orchestrator-image, orchestrator-hooks,
orchestrator-github-checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:13:49 +00:00
frostebite
17a0ea3776 test(orchestrator): add unit tests for untested core services
Adds 64 new mock-based unit tests covering orchestrator services that
previously had zero test coverage:

- TaskParameterSerializer: env var format conversion, round-trip,
  uniqBy deduplication, blocked params, default secrets
- FollowLogStreamService: build output message parsing — end of
  transmission, build success/failure detection, error accumulation,
  Library rebuild detection
- OrchestratorNamespace (guid): GUID generation format, platform
  name normalization, nanoid uniqueness
- OrchestratorFolders: path computation for all folder getters,
  ToLinuxFolder conversion, repo URL generation, purge flag detection

All tests are pure mock-based and run without any external
infrastructure (no LocalStack, K8s, Docker, or AWS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:08:49 +00:00
17 changed files with 578 additions and 2120 deletions

View File

@@ -196,6 +196,15 @@ jobs:
fi
- run: yarn install --frozen-lockfile
# ==========================================
# FAST UNIT TESTS (no infra required, fast-fail gate)
# ==========================================
- name: Run orchestrator unit tests (fast, no infra)
timeout-minutes: 2
run: >-
yarn run test
--testPathPattern="orchestrator-guid|orchestrator-folders|task-parameter-serializer|follow-log-stream-service|runner-availability-service|provider-url-parser|provider-loader|provider-git-manager|orchestrator-image|orchestrator-hooks|orchestrator-github-checks"
--verbose --detectOpenHandles --forceExit --runInBand
# ==========================================
# K8S TESTS SECTION
# ==========================================
- name: Clean up disk space before K8s tests

View File

@@ -269,28 +269,6 @@ inputs:
default: 'false'
required: false
description: 'Skip the activation/deactivation of Unity. This assumes Unity is already activated.'
artifactOutputTypes:
description: 'Comma-separated list of output types to collect (build, logs, test-results, coverage, images, metrics, data-export, server-build, custom)'
required: false
default: 'build,logs,test-results'
artifactUploadTarget:
description: 'Where to upload artifacts: github-artifacts, storage, local, none'
required: false
default: 'github-artifacts'
artifactUploadPath:
description: 'Destination path for artifact upload (storage URI or local path)'
required: false
artifactCompression:
description: 'Compression for artifacts: none, gzip, lz4'
required: false
default: 'gzip'
artifactRetentionDays:
description: 'Retention period for uploaded artifacts in days'
required: false
default: '30'
artifactCustomTypes:
description: 'JSON string defining custom output types [{name, defaultPath, description}]'
required: false
cloneDepth:
default: '50'
required: false
@@ -314,8 +292,6 @@ outputs:
'Returns the exit code from the build scripts. This code is 0 if the build was successful. If there was an error
during activation, the code is from the activation step. If activation is successful, the code is from the project
build step.'
artifactManifestPath:
description: 'Path to the generated artifact manifest JSON file'
branding:
icon: 'box'
color: 'gray-dark'

618
dist/index.js generated vendored
View File

@@ -34,14 +34,10 @@ 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()) {
@@ -74,44 +70,6 @@ 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}`);
}
@@ -417,12 +375,6 @@ 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) {
@@ -1871,24 +1823,6 @@ 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';
}
@@ -9698,550 +9632,6 @@ 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 node_child_process_1 = __nccwpck_require__(17718);
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;
/**
* Minimum valid storage URI pattern: "remote:path" or "remote:".
* rclone requires at least a remote name followed by a colon.
*/
const STORAGE_URI_PATTERN = /^[a-zA-Z][\w-]*:/;
/**
* Check whether rclone is installed and available on PATH.
* Returns true if `rclone version` executes successfully.
*/
function isRcloneAvailable() {
try {
(0, node_child_process_1.execFileSync)('rclone', ['version'], { stdio: 'pipe', timeout: 5000 });
return true;
}
catch {
return false;
}
}
/**
* Validate that a storage destination URI has the correct rclone format.
* Valid format: "remoteName:path" (e.g., "s3:bucket/prefix", "gdrive:folder").
*/
function isValidStorageUri(uri) {
return STORAGE_URI_PATTERN.test(uri);
}
/**
* 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.
*
* Validates rclone availability and destination URI format before attempting
* the upload. If rclone is not installed, falls back to local copy when a
* local-compatible destination is provided, or skips with a clear error.
*/
static async uploadToStorage(entry, resolvedPath, config) {
if (!config.destination) {
throw new Error('Storage upload requires a destination URI in artifactUploadPath');
}
// Validate storage URI format before attempting upload
if (!isValidStorageUri(config.destination)) {
throw new Error(`Invalid storage destination URI: "${config.destination}". ` +
'Expected rclone remote format "remoteName:path" (e.g., "s3:my-bucket/artifacts", "gdrive:builds").');
}
// Check rclone availability before attempting upload
if (!isRcloneAvailable()) {
orchestrator_logger_1.default.error('rclone is not installed or not in PATH. ' +
'Install rclone (https://rclone.org/install/) to use storage-based artifact upload. ' +
'Falling back to local copy.');
// Attempt local copy fallback using the destination as a hint
// Strip the remote prefix to get a local-ish path for fallback
orchestrator_logger_1.default.logWarning(`[ArtifactUpload] Storage upload skipped for '${entry.type}' — rclone not available`);
throw new Error('rclone is not installed or not in PATH. ' +
'Install rclone from https://rclone.org/install/ to use storage-based artifact upload.');
}
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:
@@ -359143,14 +358533,6 @@ try {
} catch (er) {}
/***/ }),
/***/ 89346:
/***/ ((module) => {
module.exports = eval("require")("@actions/artifact");
/***/ }),
/***/ 71269:

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,8 @@
import * as core from '@actions/core';
import path from 'node:path';
import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output } from './model';
import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup';
import { OutputService } from './model/orchestrator/services/output/output-service';
import { OutputTypeRegistry } from './model/orchestrator/services/output/output-type-registry';
import { ArtifactUploadHandler } from './model/orchestrator/services/output/artifact-upload-handler';
async function runMain() {
try {
@@ -46,64 +42,6 @@ async function runMain() {
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
await 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) {
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 as Error).message}`);
}
}
// Collect outputs and generate manifest
const manifestPath = path.join(buildParameters.projectPath, 'output-manifest.json');
const manifest = await OutputService.collectOutputs(
buildParameters.projectPath,
buildParameters.buildGuid,
buildParameters.artifactOutputTypes,
manifestPath,
);
core.setOutput('artifactManifestPath', manifestPath);
// Upload artifacts
const uploadConfig = ArtifactUploadHandler.parseConfig(
buildParameters.artifactUploadTarget,
buildParameters.artifactUploadPath || undefined,
buildParameters.artifactCompression,
buildParameters.artifactRetentionDays,
);
const uploadResult = await 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 as Error).message}`);
}
if (exitCode !== 0) {
core.setFailed(`Build failed with exit code ${exitCode}`);
}

View File

@@ -106,12 +106,6 @@ class BuildParameters {
public cacheUnityInstallationOnMac!: boolean;
public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: string;
public artifactOutputTypes!: string;
public artifactUploadTarget!: string;
public artifactUploadPath!: string;
public artifactCompression!: string;
public artifactRetentionDays!: string;
public artifactCustomTypes!: string;
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
@@ -248,12 +242,6 @@ class BuildParameters {
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath,
artifactOutputTypes: Input.artifactOutputTypes,
artifactUploadTarget: Input.artifactUploadTarget,
artifactUploadPath: Input.artifactUploadPath,
artifactCompression: Input.artifactCompression,
artifactRetentionDays: Input.artifactRetentionDays,
artifactCustomTypes: Input.artifactCustomTypes,
};
}

View File

@@ -278,30 +278,6 @@ class Input {
return Input.getInput('containerRegistryImageVersion') ?? '3';
}
static get artifactOutputTypes(): string {
return Input.getInput('artifactOutputTypes') ?? 'build,logs,test-results';
}
static get artifactUploadTarget(): string {
return Input.getInput('artifactUploadTarget') ?? 'github-artifacts';
}
static get artifactUploadPath(): string {
return Input.getInput('artifactUploadPath') ?? '';
}
static get artifactCompression(): string {
return Input.getInput('artifactCompression') ?? 'gzip';
}
static get artifactRetentionDays(): string {
return Input.getInput('artifactRetentionDays') ?? '30';
}
static get artifactCustomTypes(): string {
return Input.getInput('artifactCustomTypes') ?? '';
}
static get skipActivation(): string {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}

View File

@@ -0,0 +1,161 @@
import { OrchestratorFolders } from './orchestrator-folders';
// Mock Orchestrator
jest.mock('../orchestrator', () => ({
__esModule: true,
default: {
buildParameters: {
buildGuid: 'test-guid-abc',
cacheKey: 'my-cache-key',
projectPath: 'test-project',
buildPath: 'Builds',
maxRetainedWorkspaces: 0,
gitPrivateToken: 'ghp_test123',
orchestratorRepoName: 'game-ci/unity-builder',
githubRepo: 'user/my-game',
},
lockedWorkspace: '',
},
}));
jest.mock('../../build-parameters', () => ({
__esModule: true,
default: {
shouldUseRetainedWorkspaceMode: jest.fn().mockReturnValue(false),
},
}));
jest.mock('./orchestrator-options', () => ({
__esModule: true,
default: {
useSharedBuilder: false,
},
}));
// Normalize paths for cross-platform test compatibility
const normalize = (p: string) => p.replace(/\\/g, '/');
describe('OrchestratorFolders', () => {
describe('static constants', () => {
it('repositoryFolder is "repo"', () => {
expect(OrchestratorFolders.repositoryFolder).toBe('repo');
});
it('buildVolumeFolder is "data"', () => {
expect(OrchestratorFolders.buildVolumeFolder).toBe('data');
});
it('cacheFolder is "cache"', () => {
expect(OrchestratorFolders.cacheFolder).toBe('cache');
});
});
describe('ToLinuxFolder', () => {
it('converts backslashes to forward slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('C:\\Users\\test\\project')).toBe('C:/Users/test/project');
});
it('preserves forward slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('/home/user/project')).toBe('/home/user/project');
});
it('handles mixed slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('some/path\\mixed/slashes\\here')).toBe('some/path/mixed/slashes/here');
});
it('handles empty string', () => {
expect(OrchestratorFolders.ToLinuxFolder('')).toBe('');
});
});
describe('path computations (non-retained workspace mode)', () => {
it('uniqueOrchestratorJobFolderAbsolute uses buildGuid', () => {
const result = normalize(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute);
expect(result).toBe('/data/test-guid-abc');
});
it('cacheFolderForAllFull returns /data/cache', () => {
const result = normalize(OrchestratorFolders.cacheFolderForAllFull);
expect(result).toBe('/data/cache');
});
it('cacheFolderForCacheKeyFull includes cache key', () => {
const result = normalize(OrchestratorFolders.cacheFolderForCacheKeyFull);
expect(result).toBe('/data/cache/my-cache-key');
});
it('repoPathAbsolute is under job folder', () => {
const result = normalize(OrchestratorFolders.repoPathAbsolute);
expect(result).toBe('/data/test-guid-abc/repo');
});
it('projectPathAbsolute includes project path', () => {
const result = normalize(OrchestratorFolders.projectPathAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/test-project');
});
it('libraryFolderAbsolute is under project path', () => {
const result = normalize(OrchestratorFolders.libraryFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/test-project/Library');
});
it('projectBuildFolderAbsolute uses buildPath', () => {
const result = normalize(OrchestratorFolders.projectBuildFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/Builds');
});
it('lfsFolderAbsolute is under .git/lfs', () => {
const result = normalize(OrchestratorFolders.lfsFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/.git/lfs');
});
it('lfsCacheFolderFull is under cache key', () => {
const result = normalize(OrchestratorFolders.lfsCacheFolderFull);
expect(result).toBe('/data/cache/my-cache-key/lfs');
});
it('libraryCacheFolderFull is under cache key', () => {
const result = normalize(OrchestratorFolders.libraryCacheFolderFull);
expect(result).toBe('/data/cache/my-cache-key/Library');
});
});
describe('builderPathAbsolute', () => {
it('uses job folder when shared builder is disabled', () => {
const result = normalize(OrchestratorFolders.builderPathAbsolute);
expect(result).toBe('/data/test-guid-abc/builder');
});
});
describe('repo URLs', () => {
it('unityBuilderRepoUrl includes token and repo name', () => {
const url = OrchestratorFolders.unityBuilderRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/game-ci/unity-builder.git');
});
it('targetBuildRepoUrl includes token and github repo', () => {
const url = OrchestratorFolders.targetBuildRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/user/my-game.git');
});
});
describe('purgeRemoteCaching', () => {
it('returns false when env var is not set', () => {
const original = process.env.PURGE_REMOTE_BUILDER_CACHE;
delete process.env.PURGE_REMOTE_BUILDER_CACHE;
expect(OrchestratorFolders.purgeRemoteCaching).toBe(false);
if (original !== undefined) process.env.PURGE_REMOTE_BUILDER_CACHE = original;
});
it('returns true when env var is set', () => {
const original = process.env.PURGE_REMOTE_BUILDER_CACHE;
process.env.PURGE_REMOTE_BUILDER_CACHE = 'true';
expect(OrchestratorFolders.purgeRemoteCaching).toBe(true);
if (original !== undefined) {
process.env.PURGE_REMOTE_BUILDER_CACHE = original;
} else {
delete process.env.PURGE_REMOTE_BUILDER_CACHE;
}
});
});
});

View File

@@ -0,0 +1,53 @@
import OrchestratorNamespace from './orchestrator-guid';
describe('OrchestratorNamespace', () => {
describe('generateGuid', () => {
it('generates a guid with correct format', () => {
const guid = OrchestratorNamespace.generateGuid('42', 'StandaloneLinux64');
// Format: {runNumber}-{platform}-{nanoid4}
expect(guid).toMatch(/^42-linux64-[a-z0-9]{4}$/);
});
it('strips "standalone" prefix from platform (case-insensitive)', () => {
const guid = OrchestratorNamespace.generateGuid('1', 'StandaloneWindows64');
expect(guid).toMatch(/^1-windows64-[a-z0-9]{4}$/);
});
it('lowercases platform name', () => {
const guid = OrchestratorNamespace.generateGuid('5', 'Android');
expect(guid).toMatch(/^5-android-[a-z0-9]{4}$/);
});
it('handles numeric run number', () => {
const guid = OrchestratorNamespace.generateGuid(100, 'iOS');
expect(guid).toMatch(/^100-ios-[a-z0-9]{4}$/);
});
it('generates unique guids on repeated calls', () => {
const guids = new Set<string>();
for (let i = 0; i < 20; i++) {
guids.add(OrchestratorNamespace.generateGuid('1', 'StandaloneLinux64'));
}
// With 4 alphanumeric chars (36^4 = ~1.7M possibilities), 20 calls should almost certainly be unique
expect(guids.size).toBeGreaterThan(1);
});
it('handles StandaloneOSX platform', () => {
const guid = OrchestratorNamespace.generateGuid('7', 'StandaloneOSX');
expect(guid).toMatch(/^7-osx-[a-z0-9]{4}$/);
});
it('handles WebGL platform (no standalone prefix)', () => {
const guid = OrchestratorNamespace.generateGuid('3', 'WebGL');
expect(guid).toMatch(/^3-webgl-[a-z0-9]{4}$/);
});
it('uses only lowercase alphanumeric characters in nanoid portion', () => {
for (let i = 0; i < 10; i++) {
const guid = OrchestratorNamespace.generateGuid('1', 'test');
const nanoidPart = guid.split('-').pop()!;
expect(nanoidPart).toMatch(/^[0-9a-z]{4}$/);
}
});
});
});

View File

@@ -0,0 +1,147 @@
import { FollowLogStreamService } from './follow-log-stream-service';
import * as core from '@actions/core';
import GitHub from '../../../github';
// Mock dependencies
jest.mock('../../../github', () => ({
__esModule: true,
default: {
updateGitHubCheck: jest.fn(),
githubInputEnabled: false,
},
}));
jest.mock('@actions/core', () => ({
warning: jest.fn(),
setOutput: jest.fn(),
setFailed: jest.fn(),
error: jest.fn(),
getInput: jest.fn().mockReturnValue(''),
}));
jest.mock('../../orchestrator', () => ({
__esModule: true,
default: {
buildParameters: {
logId: 'test-log-id-123',
},
},
}));
jest.mock('../../options/orchestrator-statics', () => ({
OrchestratorStatics: {
logPrefix: 'TEST',
},
}));
jest.mock('./orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
},
}));
describe('FollowLogStreamService', () => {
beforeEach(() => {
jest.clearAllMocks();
FollowLogStreamService.Reset();
FollowLogStreamService.errors = '';
});
describe('Reset', () => {
it('resets DidReceiveEndOfTransmission to false', () => {
FollowLogStreamService.DidReceiveEndOfTransmission = true;
FollowLogStreamService.Reset();
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(false);
});
});
describe('handleIteration', () => {
it('detects end of transmission marker', () => {
const result = FollowLogStreamService.handleIteration('---test-log-id-123', true, false, '');
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(true);
expect(result.shouldReadLogs).toBe(false);
});
it('does not trigger end of transmission for non-matching log ID', () => {
const result = FollowLogStreamService.handleIteration('---different-log-id', true, false, '');
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(false);
expect(result.shouldReadLogs).toBe(true);
});
it('detects Library rebuild message', () => {
FollowLogStreamService.handleIteration(
'Rebuilding Library because the asset database could not be found!',
true,
false,
'',
);
expect(GitHub.updateGitHubCheck).toHaveBeenCalledWith('Library was not found, importing new Library', '');
expect(core.warning).toHaveBeenCalledWith('LIBRARY NOT FOUND!');
expect(core.setOutput).toHaveBeenCalledWith('library-found', 'false');
});
it('detects Build succeeded message', () => {
FollowLogStreamService.handleIteration('Build succeeded', true, false, '');
expect(GitHub.updateGitHubCheck).toHaveBeenCalledWith('Build succeeded', 'Build succeeded');
expect(core.setOutput).toHaveBeenCalledWith('build-result', 'success');
});
it('detects Build fail message', () => {
FollowLogStreamService.handleIteration('Build fail', true, false, '');
expect(GitHub.updateGitHubCheck).toHaveBeenCalled();
expect(core.setOutput).toHaveBeenCalledWith('build-result', 'failed');
expect(core.setFailed).toHaveBeenCalledWith('unity build failed');
expect(core.error).toHaveBeenCalledWith('BUILD FAILED!');
});
it('accumulates error messages with "error " pattern', () => {
FollowLogStreamService.handleIteration('error CS0001: Something went wrong', true, false, '');
expect(FollowLogStreamService.errors).toContain('error CS0001: Something went wrong');
expect(core.error).toHaveBeenCalled();
});
it('accumulates error messages with "error: " pattern', () => {
FollowLogStreamService.handleIteration('Fatal Error: Out of memory', true, false, '');
expect(FollowLogStreamService.errors).toContain('Fatal Error: Out of memory');
});
it('accumulates "command failed: " messages', () => {
FollowLogStreamService.handleIteration('command failed: git pull', true, false, '');
expect(FollowLogStreamService.errors).toContain('command failed: git pull');
});
it('accumulates "invalid " messages', () => {
FollowLogStreamService.handleIteration('invalid configuration value', true, false, '');
expect(FollowLogStreamService.errors).toContain('invalid configuration value');
});
it('accumulates "cannot be found" messages', () => {
FollowLogStreamService.handleIteration('Assembly cannot be found', true, false, '');
expect(FollowLogStreamService.errors).toContain('Assembly cannot be found');
});
it('appends message to output', () => {
const result = FollowLogStreamService.handleIteration('Some normal log line', true, false, 'previous output\n');
expect(result.output).toContain('Some normal log line');
expect(result.output).toContain('previous output');
});
it('preserves shouldCleanup value', () => {
const result = FollowLogStreamService.handleIteration('normal message', true, true, '');
expect(result.shouldCleanup).toBe(true);
});
it('does not change shouldReadLogs for normal messages', () => {
const result = FollowLogStreamService.handleIteration('Just a regular build log', true, false, '');
expect(result.shouldReadLogs).toBe(true);
});
it('includes accumulated errors in Build fail GitHub check message', () => {
FollowLogStreamService.errors = '\nprevious error';
FollowLogStreamService.handleIteration('Build fail', true, false, '');
const updateCall = (GitHub.updateGitHubCheck as jest.Mock).mock.calls[0];
expect(updateCall[0]).toContain('previous error');
});
});
});

View File

@@ -0,0 +1,207 @@
import { TaskParameterSerializer } from './task-parameter-serializer';
// Mock dependencies that TaskParameterSerializer uses internally
jest.mock('@actions/core', () => ({
getInput: jest.fn().mockReturnValue(''),
setOutput: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
}));
jest.mock('../../options/orchestrator-options', () => ({
__esModule: true,
default: {
getInput: jest.fn().mockReturnValue(undefined),
ToEnvVarFormat: (input: string) => {
if (input.toUpperCase() === input) {
return input;
}
return input
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase()
.replace(/ /g, '_');
},
},
}));
jest.mock('../../options/orchestrator-options-reader', () => ({
__esModule: true,
default: {
GetProperties: jest.fn().mockReturnValue([]),
},
}));
jest.mock('../../options/orchestrator-query-override', () => ({
__esModule: true,
default: {
queryOverrides: undefined,
},
}));
jest.mock('../hooks/command-hook-service', () => ({
CommandHookService: {
getHooks: jest.fn().mockReturnValue([]),
getSecrets: jest.fn().mockReturnValue([]),
},
}));
jest.mock('../../../input', () => ({
__esModule: true,
default: {},
}));
jest.mock('../../../github', () => ({
__esModule: true,
default: {
githubInputEnabled: false,
},
}));
describe('TaskParameterSerializer', () => {
describe('ToEnvVarFormat', () => {
it('converts camelCase to UPPER_SNAKE_CASE', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('targetPlatform')).toBe('TARGET_PLATFORM');
});
it('converts single word to uppercase', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('version')).toBe('VERSION');
});
it('preserves already-uppercase strings', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('AWS_REGION')).toBe('AWS_REGION');
});
it('handles multi-word camelCase', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('buildPlatformTarget')).toBe('BUILD_PLATFORM_TARGET');
});
it('handles string starting with uppercase', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('BuildGuid')).toBe('BUILD_GUID');
});
});
describe('UndoEnvVarFormat', () => {
it('converts UPPER_SNAKE_CASE back to camelCase', () => {
expect(TaskParameterSerializer.UndoEnvVarFormat('TARGET_PLATFORM')).toBe('targetPlatform');
});
it('handles single word', () => {
expect(TaskParameterSerializer.UndoEnvVarFormat('VERSION')).toBe('version');
});
it('handles multiple underscores', () => {
expect(TaskParameterSerializer.UndoEnvVarFormat('BUILD_PLATFORM_TARGET')).toBe('buildPlatformTarget');
});
});
describe('round-trip conversion', () => {
it('ToEnvVarFormat -> UndoEnvVarFormat returns original for simple camelCase', () => {
const original = 'targetPlatform';
const envVar = TaskParameterSerializer.ToEnvVarFormat(original);
const roundTrip = TaskParameterSerializer.UndoEnvVarFormat(envVar);
expect(roundTrip).toBe(original);
});
it('round-trips multi-word keys', () => {
const original = 'cacheKey';
const envVar = TaskParameterSerializer.ToEnvVarFormat(original);
const roundTrip = TaskParameterSerializer.UndoEnvVarFormat(envVar);
expect(roundTrip).toBe(original);
});
});
describe('uniqBy', () => {
it('removes duplicates by key function', () => {
const items = [
{ name: 'A', value: '1' },
{ name: 'B', value: '2' },
{ name: 'A', value: '3' },
];
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
expect(result).toHaveLength(2);
expect(result[0].value).toBe('1');
expect(result[1].value).toBe('2');
});
it('returns all items when no duplicates', () => {
const items = [
{ name: 'A', value: '1' },
{ name: 'B', value: '2' },
{ name: 'C', value: '3' },
];
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
expect(result).toHaveLength(3);
});
it('handles empty array', () => {
const result = TaskParameterSerializer.uniqBy([], (x) => x.name);
expect(result).toHaveLength(0);
});
it('keeps first occurrence when duplicates exist', () => {
const items = [
{ name: 'KEY', value: 'first' },
{ name: 'KEY', value: 'second' },
{ name: 'KEY', value: 'third' },
];
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
expect(result).toHaveLength(1);
expect(result[0].value).toBe('first');
});
});
describe('blockedParameterNames', () => {
it('contains expected blocked names', () => {
expect(TaskParameterSerializer.blockedParameterNames.has('0')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('length')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('prototype')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('unityVersion')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('CUSTOM_JOB')).toBe(true);
});
it('does not block valid parameter names', () => {
expect(TaskParameterSerializer.blockedParameterNames.has('targetPlatform')).toBe(false);
expect(TaskParameterSerializer.blockedParameterNames.has('buildGuid')).toBe(false);
expect(TaskParameterSerializer.blockedParameterNames.has('cacheKey')).toBe(false);
});
});
describe('readDefaultSecrets', () => {
it('returns an array', () => {
const secrets = TaskParameterSerializer.readDefaultSecrets();
expect(Array.isArray(secrets)).toBe(true);
});
it('includes secrets from environment when present', () => {
const originalSerial = process.env.UNITY_SERIAL;
process.env.UNITY_SERIAL = 'test-serial';
const secrets = TaskParameterSerializer.readDefaultSecrets();
const serialSecret = secrets.find((s) => s.ParameterKey === 'UNITY_SERIAL');
expect(serialSecret).toBeDefined();
expect(serialSecret?.ParameterValue).toBe('test-serial');
if (originalSerial !== undefined) {
process.env.UNITY_SERIAL = originalSerial;
} else {
delete process.env.UNITY_SERIAL;
}
});
it('excludes secrets not in environment', () => {
const originalSerial = process.env.UNITY_SERIAL;
delete process.env.UNITY_SERIAL;
const secrets = TaskParameterSerializer.readDefaultSecrets();
const serialSecret = secrets.find((s) => s.ParameterKey === 'UNITY_SERIAL');
expect(serialSecret).toBeUndefined();
if (originalSerial !== undefined) {
process.env.UNITY_SERIAL = originalSerial;
}
});
});
});

View File

@@ -1,607 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { OutputTypeRegistry, OutputTypeDefinition } from './output-type-registry';
import { OutputService } from './output-service';
import { OutputManifest } from './output-manifest';
import { ArtifactUploadHandler, ArtifactUploadConfig } from './artifact-upload-handler';
// Mock node:fs
jest.mock('node:fs');
const mockedFs = fs as jest.Mocked<typeof fs>;
// Mock @actions/core (used by OrchestratorLogger)
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(),
setFailed: jest.fn(),
setSecret: jest.fn(),
}));
// Mock @actions/exec (used by upload handler for rclone)
jest.mock('@actions/exec', () => ({
exec: jest.fn().mockResolvedValue(0),
}));
afterEach(() => {
jest.restoreAllMocks();
OutputTypeRegistry.resetCustomTypes();
});
// ---------------------------------------------------------------------------
// OutputTypeRegistry Tests
// ---------------------------------------------------------------------------
describe('OutputTypeRegistry', () => {
describe('built-in types', () => {
it('should have 8 built-in types', () => {
const allTypes = OutputTypeRegistry.getAllTypes();
const builtInTypes = allTypes.filter((t) => t.builtIn);
expect(builtInTypes).toHaveLength(8);
});
it.each(['build', 'test-results', 'server-build', 'data-export', 'images', 'logs', 'metrics', 'coverage'])(
'should include built-in type "%s"',
(typeName) => {
const typeDef = OutputTypeRegistry.getType(typeName);
expect(typeDef).toBeDefined();
expect(typeDef!.name).toBe(typeName);
expect(typeDef!.builtIn).toBe(true);
},
);
it('should return undefined for unknown types', () => {
const typeDef = OutputTypeRegistry.getType('nonexistent');
expect(typeDef).toBeUndefined();
});
it('should include default paths for all built-in types', () => {
const allTypes = OutputTypeRegistry.getAllTypes();
for (const typeDef of allTypes) {
expect(typeDef.defaultPath).toBeTruthy();
expect(typeof typeDef.defaultPath).toBe('string');
}
});
it('should include descriptions for all built-in types', () => {
const allTypes = OutputTypeRegistry.getAllTypes();
for (const typeDef of allTypes) {
expect(typeDef.description).toBeTruthy();
expect(typeof typeDef.description).toBe('string');
}
});
});
describe('custom type registration', () => {
it('should register a custom type', () => {
const customType: OutputTypeDefinition = {
name: 'custom-reports',
defaultPath: './Reports/',
description: 'Custom generated reports',
builtIn: false,
};
OutputTypeRegistry.registerType(customType);
const retrieved = OutputTypeRegistry.getType('custom-reports');
expect(retrieved).toBeDefined();
expect(retrieved!.name).toBe('custom-reports');
expect(retrieved!.builtIn).toBe(false);
});
it('should not override built-in types', () => {
const override: OutputTypeDefinition = {
name: 'build',
defaultPath: './Override/',
description: 'Should not override',
builtIn: false,
};
OutputTypeRegistry.registerType(override);
const buildType = OutputTypeRegistry.getType('build');
expect(buildType!.defaultPath).not.toBe('./Override/');
expect(buildType!.builtIn).toBe(true);
});
it('should include custom types in getAllTypes', () => {
OutputTypeRegistry.registerType({
name: 'custom-a',
defaultPath: './A/',
description: 'Custom A',
builtIn: false,
});
const allTypes = OutputTypeRegistry.getAllTypes();
expect(allTypes.length).toBe(9); // 8 built-in + 1 custom
expect(allTypes.some((t) => t.name === 'custom-a')).toBe(true);
});
it('should reset custom types', () => {
OutputTypeRegistry.registerType({
name: 'temp-type',
defaultPath: './Temp/',
description: 'Temporary type',
builtIn: false,
});
expect(OutputTypeRegistry.getType('temp-type')).toBeDefined();
OutputTypeRegistry.resetCustomTypes();
expect(OutputTypeRegistry.getType('temp-type')).toBeUndefined();
});
it('should force builtIn to false when registering custom types', () => {
OutputTypeRegistry.registerType({
name: 'sneaky',
defaultPath: './Sneaky/',
description: 'Tries to be built-in',
builtIn: true, // Intentionally setting to true
});
const retrieved = OutputTypeRegistry.getType('sneaky');
expect(retrieved).toBeDefined();
expect(retrieved!.builtIn).toBe(false);
});
});
describe('parseOutputTypes', () => {
it('should parse a comma-separated string of valid types', () => {
const types = OutputTypeRegistry.parseOutputTypes('build,logs,coverage');
expect(types).toHaveLength(3);
expect(types.map((t) => t.name)).toEqual(['build', 'logs', 'coverage']);
});
it('should skip unknown types', () => {
const types = OutputTypeRegistry.parseOutputTypes('build,unknown,logs');
expect(types).toHaveLength(2);
expect(types.map((t) => t.name)).toEqual(['build', 'logs']);
});
it('should handle empty string', () => {
const types = OutputTypeRegistry.parseOutputTypes('');
expect(types).toHaveLength(0);
});
it('should handle whitespace in type names', () => {
const types = OutputTypeRegistry.parseOutputTypes(' build , logs , coverage ');
expect(types).toHaveLength(3);
});
it('should include custom types when parsing', () => {
OutputTypeRegistry.registerType({
name: 'my-reports',
defaultPath: './Reports/',
description: 'Custom reports',
builtIn: false,
});
const types = OutputTypeRegistry.parseOutputTypes('build,my-reports');
expect(types).toHaveLength(2);
expect(types[1].name).toBe('my-reports');
});
});
});
// ---------------------------------------------------------------------------
// OutputService Tests
// ---------------------------------------------------------------------------
describe('OutputService', () => {
const projectPath = '/project';
const buildGuid = 'test-guid-1234';
beforeEach(() => {
// Reset all fs mocks
mockedFs.existsSync.mockReset();
mockedFs.statSync.mockReset();
mockedFs.readdirSync.mockReset();
mockedFs.writeFileSync.mockReset();
mockedFs.mkdirSync.mockReset();
});
describe('collectOutputs', () => {
it('should return an empty manifest when no output types are declared', async () => {
const manifest = await OutputService.collectOutputs(projectPath, buildGuid, '');
expect(manifest.buildGuid).toBe(buildGuid);
expect(manifest.outputs).toHaveLength(0);
expect(manifest.timestamp).toBeTruthy();
});
it('should skip outputs where the path does not exist', async () => {
mockedFs.existsSync.mockReturnValue(false);
const manifest = await OutputService.collectOutputs(projectPath, buildGuid, 'build,logs');
expect(manifest.outputs).toHaveLength(0);
});
it('should collect directory outputs with file listings', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => true, size: 0 } as any);
mockedFs.readdirSync.mockImplementation((_dirPath: any, options?: any) => {
if (options?.withFileTypes) {
return [
{ name: 'file1.txt', isDirectory: () => false },
{ name: 'file2.txt', isDirectory: () => false },
] as any;
}
return ['file1.txt', 'file2.txt'] as any;
});
const manifest = await OutputService.collectOutputs(projectPath, buildGuid, 'logs');
expect(manifest.outputs).toHaveLength(1);
expect(manifest.outputs[0].type).toBe('logs');
expect(manifest.outputs[0].files).toEqual(['file1.txt', 'file2.txt']);
});
it('should collect file output with correct size', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 4096 } as any);
const manifest = await OutputService.collectOutputs(projectPath, buildGuid, 'coverage');
expect(manifest.outputs).toHaveLength(1);
expect(manifest.outputs[0].size).toBe(4096);
});
it('should write manifest to disk when manifestPath is provided', async () => {
// existsSync returns false for output paths (no outputs found) but mkdirSync/writeFileSync should still be called
// The service only writes manifest when at least one output type is declared and types are resolved
// So we need to provide a valid output type and have its path exist
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 100 } as any);
mockedFs.mkdirSync.mockReturnValue(undefined);
mockedFs.writeFileSync.mockImplementation(() => {});
const manifestPath = '/output/manifest.json';
await OutputService.collectOutputs(projectPath, buildGuid, 'logs', manifestPath);
expect(mockedFs.mkdirSync).toHaveBeenCalledWith(path.dirname(manifestPath), { recursive: true });
expect(mockedFs.writeFileSync).toHaveBeenCalledWith(manifestPath, expect.any(String), 'utf8');
});
it('should generate valid JSON in the manifest file', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 200 } as any);
mockedFs.mkdirSync.mockReturnValue(undefined);
mockedFs.writeFileSync.mockImplementation(() => {});
const manifestPath = '/output/manifest.json';
await OutputService.collectOutputs(projectPath, buildGuid, 'coverage', manifestPath);
const writtenContent = (mockedFs.writeFileSync as jest.Mock).mock.calls[0][1];
const parsed = JSON.parse(writtenContent);
expect(parsed.buildGuid).toBe(buildGuid);
expect(Array.isArray(parsed.outputs)).toBe(true);
expect(parsed.outputs.length).toBeGreaterThan(0);
});
it('should set a valid ISO 8601 timestamp', async () => {
const manifest = await OutputService.collectOutputs(projectPath, buildGuid, '');
const parsed = new Date(manifest.timestamp);
expect(parsed.toISOString()).toBe(manifest.timestamp);
});
});
});
// ---------------------------------------------------------------------------
// ArtifactUploadHandler Tests
// ---------------------------------------------------------------------------
describe('ArtifactUploadHandler', () => {
const projectPath = '/project';
beforeEach(() => {
mockedFs.existsSync.mockReset();
mockedFs.statSync.mockReset();
mockedFs.readdirSync.mockReset();
mockedFs.mkdirSync.mockReset();
mockedFs.copyFileSync.mockReset();
});
describe('parseConfig', () => {
it('should parse valid config values', () => {
const config = ArtifactUploadHandler.parseConfig('github-artifacts', '/dest', 'gzip', '14');
expect(config.target).toBe('github-artifacts');
expect(config.destination).toBe('/dest');
expect(config.compression).toBe('gzip');
expect(config.retentionDays).toBe(14);
});
it('should default invalid target to github-artifacts', () => {
const config = ArtifactUploadHandler.parseConfig('invalid', undefined, 'none', '30');
expect(config.target).toBe('github-artifacts');
});
it('should default invalid compression to gzip', () => {
const config = ArtifactUploadHandler.parseConfig('local', '/dest', 'brotli', '30');
expect(config.compression).toBe('gzip');
});
it('should default invalid retention to 30 days', () => {
const config = ArtifactUploadHandler.parseConfig('local', '/dest', 'gzip', 'abc');
expect(config.retentionDays).toBe(30);
});
it('should default negative retention to 30 days', () => {
const config = ArtifactUploadHandler.parseConfig('local', '/dest', 'gzip', '-5');
expect(config.retentionDays).toBe(30);
});
it('should set destination to undefined when empty string', () => {
const config = ArtifactUploadHandler.parseConfig('storage', '', 'none', '7');
expect(config.destination).toBeUndefined();
});
});
describe('uploadArtifacts', () => {
it('should skip upload when target is none', async () => {
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'build', path: './Builds/' }],
};
const config: ArtifactUploadConfig = {
target: 'none',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(true);
expect(result.entries).toHaveLength(0);
});
it('should return success with no entries for empty manifest', async () => {
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [],
};
const config: ArtifactUploadConfig = {
target: 'github-artifacts',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(true);
expect(result.entries).toHaveLength(0);
expect(result.totalBytes).toBe(0);
});
it('should fail entry when output path does not exist', async () => {
mockedFs.existsSync.mockReturnValue(false);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'build', path: './Builds/Missing/' }],
};
const config: ArtifactUploadConfig = {
target: 'local',
destination: '/output',
compression: 'none',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(false);
expect(result.entries).toHaveLength(1);
expect(result.entries[0].success).toBe(false);
expect(result.entries[0].error).toContain('does not exist');
});
it('should copy files for local upload target', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 1024 } as any);
mockedFs.mkdirSync.mockReturnValue(undefined);
mockedFs.copyFileSync.mockReturnValue(undefined);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'logs', path: './Logs/build.log', size: 1024 }],
};
const config: ArtifactUploadConfig = {
target: 'local',
destination: '/output',
compression: 'none',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(true);
expect(result.entries).toHaveLength(1);
expect(result.entries[0].success).toBe(true);
expect(result.totalBytes).toBe(1024);
});
it('should fail local upload when no destination is provided', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 512 } as any);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'logs', path: './Logs/build.log', size: 512 }],
};
const config: ArtifactUploadConfig = {
target: 'local',
compression: 'none',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(false);
expect(result.entries[0].success).toBe(false);
expect(result.entries[0].error).toContain('destination path');
});
it('should report correct duration', async () => {
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [],
};
const config: ArtifactUploadConfig = {
target: 'none',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.durationMs).toBeGreaterThanOrEqual(0);
});
});
describe('collectFiles', () => {
it('should return single file for a file path', () => {
mockedFs.statSync.mockReturnValue({ isDirectory: () => false } as any);
const files = ArtifactUploadHandler.collectFiles('/path/to/file.txt');
expect(files).toEqual(['/path/to/file.txt']);
});
it('should return all files recursively for a directory', () => {
mockedFs.statSync.mockImplementation((p: any) => {
const pathStr = typeof p === 'string' ? p : p.toString();
if (pathStr.endsWith('.txt') || pathStr.endsWith('.log')) {
return { isDirectory: () => false } as any;
}
return { isDirectory: () => true } as any;
});
mockedFs.readdirSync.mockImplementation((dirPath: any, _options?: any) => {
const dirStr = typeof dirPath === 'string' ? dirPath : dirPath.toString();
if (dirStr === '/root') {
return [
{ name: 'file1.txt', isDirectory: () => false },
{ name: 'sub', isDirectory: () => true },
] as any;
}
if (dirStr.endsWith('sub')) {
return [{ name: 'file2.log', isDirectory: () => false }] as any;
}
return [] as any;
});
const files = ArtifactUploadHandler.collectFiles('/root');
expect(files).toHaveLength(2);
expect(files).toContain(path.join('/root', 'file1.txt'));
expect(files).toContain(path.join('/root', 'sub', 'file2.log'));
});
});
describe('storage upload validation', () => {
it('should fail storage upload when no destination is provided', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
};
const config: ArtifactUploadConfig = {
target: 'storage',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(false);
expect(result.entries[0].error).toContain('destination URI');
});
it('should fail storage upload when destination URI has invalid format', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
};
const config: ArtifactUploadConfig = {
target: 'storage',
destination: '/just/a/local/path',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(false);
expect(result.entries[0].error).toContain('Invalid storage destination URI');
});
it('should fail storage upload when rclone is not installed', async () => {
// Mock child_process.execFileSync to throw (rclone not found)
const childProcess = require('node:child_process');
const originalExecFileSync = childProcess.execFileSync;
childProcess.execFileSync = jest.fn(() => {
throw new Error('ENOENT');
});
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
};
const config: ArtifactUploadConfig = {
target: 'storage',
destination: 's3:my-bucket/artifacts',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(false);
expect(result.entries[0].error).toContain('rclone is not installed');
// Restore
childProcess.execFileSync = originalExecFileSync;
});
it('should accept valid rclone storage URI formats', async () => {
// Mock child_process.execFileSync to succeed (rclone available)
const childProcess = require('node:child_process');
const originalExecFileSync = childProcess.execFileSync;
childProcess.execFileSync = jest.fn(() => 'rclone v1.65.0');
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
};
// s3:bucket format should pass URI validation and reach the exec call
const config: ArtifactUploadConfig = {
target: 'storage',
destination: 's3:my-bucket/artifacts',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
// Should succeed because exec is mocked to return 0
expect(result.entries[0].success).toBe(true);
// Restore
childProcess.execFileSync = originalExecFileSync;
});
});
});

View File

@@ -1,474 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { exec } from '@actions/exec';
import OrchestratorLogger from '../core/orchestrator-logger';
import { OutputManifest, OutputEntry } from './output-manifest';
/**
* Configuration for artifact upload.
*/
export interface ArtifactUploadConfig {
/** Upload target: 'github-artifacts', 'storage', 'local', 'none' */
target: 'github-artifacts' | 'storage' | 'local' | 'none';
/** Destination path — storage URI for 'storage', local path for 'local' */
destination?: string;
/** Compression method */
compression: 'none' | 'gzip' | 'lz4';
/** Retention period in days (GitHub Artifacts only) */
retentionDays: number;
}
/**
* Result of an artifact upload operation.
*/
export interface UploadResult {
/** Whether the upload succeeded overall */
success: boolean;
/** Per-entry upload results */
entries: UploadEntryResult[];
/** Total bytes uploaded */
totalBytes: number;
/** Duration in milliseconds */
durationMs: number;
}
export interface UploadEntryResult {
/** The output type name */
type: string;
/** The output path */
path: string;
/** Whether this entry uploaded successfully */
success: boolean;
/** Bytes uploaded for this entry */
bytes: number;
/** Error message if upload failed */
error?: string;
}
/**
* GitHub Artifacts size limit per artifact (10 GB).
* Files larger than this must be split.
*/
const GITHUB_ARTIFACT_SIZE_LIMIT = 10 * 1024 * 1024 * 1024;
/**
* Minimum valid storage URI pattern: "remote:path" or "remote:".
* rclone requires at least a remote name followed by a colon.
*/
const STORAGE_URI_PATTERN = /^[a-zA-Z][\w-]*:/;
/**
* Check whether rclone is installed and available on PATH.
* Returns true if `rclone version` executes successfully.
*/
function isRcloneAvailable(): boolean {
try {
execFileSync('rclone', ['version'], { stdio: 'pipe', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Validate that a storage destination URI has the correct rclone format.
* Valid format: "remoteName:path" (e.g., "s3:bucket/prefix", "gdrive:folder").
*/
function isValidStorageUri(uri: string): boolean {
return STORAGE_URI_PATTERN.test(uri);
}
/**
* Handles uploading build artifacts to various targets.
*/
export class ArtifactUploadHandler {
/**
* Upload artifacts described by a manifest to the configured target.
*/
static async uploadArtifacts(
manifest: OutputManifest,
config: ArtifactUploadConfig,
projectPath: string,
): Promise<UploadResult> {
const startTime = Date.now();
const result: UploadResult = {
success: true,
entries: [],
totalBytes: 0,
durationMs: 0,
};
if (config.target === 'none') {
OrchestratorLogger.log('[ArtifactUpload] Upload target is "none", skipping upload');
result.durationMs = Date.now() - startTime;
return result;
}
if (manifest.outputs.length === 0) {
OrchestratorLogger.log('[ArtifactUpload] No outputs in manifest, nothing to upload');
result.durationMs = Date.now() - startTime;
return result;
}
OrchestratorLogger.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;
OrchestratorLogger.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.
*/
private static async uploadEntry(
entry: OutputEntry,
config: ArtifactUploadConfig,
projectPath: string,
): Promise<UploadEntryResult> {
const entryResult: UploadEntryResult = {
type: entry.type,
path: entry.path,
success: false,
bytes: entry.size || 0,
};
const resolvedPath = path.resolve(
projectPath,
entry.path.replace('{platform}', process.env.BUILD_TARGET || 'Unknown'),
);
if (!fs.existsSync(resolvedPath)) {
entryResult.error = `Output path does not exist: ${resolvedPath}`;
OrchestratorLogger.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;
OrchestratorLogger.log(
`[ArtifactUpload] Uploaded '${entry.type}' (${entryResult.bytes} bytes) to ${config.target}`,
);
} catch (error: any) {
entryResult.error = error.message || String(error);
OrchestratorLogger.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.
*/
private static async uploadToGitHubArtifacts(
entry: OutputEntry,
resolvedPath: string,
config: ArtifactUploadConfig,
): Promise<void> {
// 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: any;
try {
const artifactModule = '@actions/artifact';
// eslint-disable-next-line @typescript-eslint/no-require-imports
artifact = require(artifactModule);
} 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) {
OrchestratorLogger.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) {
OrchestratorLogger.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 = fs.statSync(resolvedPath).isDirectory() ? resolvedPath : path.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.
*/
private static async uploadChunked(
artifactClient: any,
baseName: string,
files: string[],
rootDirectory: string,
config: ArtifactUploadConfig,
): Promise<void> {
const chunkSize = GITHUB_ARTIFACT_SIZE_LIMIT;
let currentChunkFiles: string[] = [];
let currentChunkSize = 0;
let chunkIndex = 0;
for (const filePath of files) {
const fileSize = fs.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,
);
}
}
private static async uploadSingleChunk(
artifactClient: any,
name: string,
files: string[],
rootDirectory: string,
config: ArtifactUploadConfig,
): Promise<void> {
OrchestratorLogger.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.
*
* Validates rclone availability and destination URI format before attempting
* the upload. If rclone is not installed, falls back to local copy when a
* local-compatible destination is provided, or skips with a clear error.
*/
private static async uploadToStorage(
entry: OutputEntry,
resolvedPath: string,
config: ArtifactUploadConfig,
): Promise<void> {
if (!config.destination) {
throw new Error('Storage upload requires a destination URI in artifactUploadPath');
}
// Validate storage URI format before attempting upload
if (!isValidStorageUri(config.destination)) {
throw new Error(
`Invalid storage destination URI: "${config.destination}". ` +
'Expected rclone remote format "remoteName:path" (e.g., "s3:my-bucket/artifacts", "gdrive:builds").',
);
}
// Check rclone availability before attempting upload
if (!isRcloneAvailable()) {
OrchestratorLogger.error(
'rclone is not installed or not in PATH. ' +
'Install rclone (https://rclone.org/install/) to use storage-based artifact upload. ' +
'Falling back to local copy.',
);
// Attempt local copy fallback using the destination as a hint
// Strip the remote prefix to get a local-ish path for fallback
OrchestratorLogger.logWarning(
`[ArtifactUpload] Storage upload skipped for '${entry.type}' — rclone not available`,
);
throw new Error(
'rclone is not installed or not in PATH. ' +
'Install rclone from https://rclone.org/install/ to use storage-based artifact upload.',
);
}
const destination = `${config.destination}/${entry.type}`;
OrchestratorLogger.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.
OrchestratorLogger.log(
`[ArtifactUpload] Note: compression '${config.compression}' is configured at the remote level for rclone`,
);
}
await exec('rclone', args);
}
/**
* Upload to a local path (copy).
*/
private static async uploadToLocal(
entry: OutputEntry,
resolvedPath: string,
config: ArtifactUploadConfig,
): Promise<void> {
if (!config.destination) {
throw new Error('Local upload requires a destination path in artifactUploadPath');
}
const destination = path.join(config.destination, entry.type);
fs.mkdirSync(destination, { recursive: true });
OrchestratorLogger.log(`[ArtifactUpload] Copying '${entry.type}' to local path: ${destination}`);
ArtifactUploadHandler.copyRecursive(resolvedPath, destination);
}
/**
* Recursively copy files from source to destination.
*/
private static copyRecursive(source: string, destination: string): void {
const stat = fs.statSync(source);
if (stat.isDirectory()) {
fs.mkdirSync(destination, { recursive: true });
const entries = fs.readdirSync(source);
for (const entry of entries) {
ArtifactUploadHandler.copyRecursive(path.join(source, entry), path.join(destination, entry));
}
} else {
fs.copyFileSync(source, destination);
}
}
/**
* Collect all files at a given path (recursively if directory).
*/
static collectFiles(targetPath: string): string[] {
const stat = fs.statSync(targetPath);
if (!stat.isDirectory()) {
return [targetPath];
}
const files: string[] = [];
const entries = fs.readdirSync(targetPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.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: string,
destination: string | undefined,
compression: string,
retentionDays: string,
): ArtifactUploadConfig {
const validTargets = ['github-artifacts', 'storage', 'local', 'none'] as const;
const resolvedTarget = validTargets.includes(target as any)
? (target as ArtifactUploadConfig['target'])
: 'github-artifacts';
const validCompressions = ['none', 'gzip', 'lz4'] as const;
const resolvedCompression = validCompressions.includes(compression as any)
? (compression as ArtifactUploadConfig['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,
};
}
}

View File

@@ -1,3 +0,0 @@
export { OutputManifest, OutputEntry } from './output-manifest';
export { OutputTypeRegistry, OutputTypeDefinition } from './output-type-registry';
export { OutputService } from './output-service';

View File

@@ -1,41 +0,0 @@
/**
* 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[];
}

View File

@@ -1,118 +0,0 @@
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;
}
}

View File

@@ -1,136 +0,0 @@
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 = {};
}
}