mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-09 07:23:54 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55b45a4a0c | |||
| ae03bd2f13 | |||
| 1e2bb889bf | |||
| 7615bbd9dd | |||
| aa2e05d468 | |||
| b3e1639029 |
+24
-36
@@ -194,42 +194,6 @@ inputs:
|
|||||||
description:
|
description:
|
||||||
'[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
|
'[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
|
||||||
be configured.'
|
be configured.'
|
||||||
fallbackProviderStrategy:
|
|
||||||
default: ''
|
|
||||||
required: false
|
|
||||||
description:
|
|
||||||
'[Orchestrator] Fallback provider when the primary is unavailable. Used with runnerCheckEnabled for automatic
|
|
||||||
failover, or as a catch-all if the primary provider fails to initialize.'
|
|
||||||
runnerCheckEnabled:
|
|
||||||
default: 'false'
|
|
||||||
required: false
|
|
||||||
description:
|
|
||||||
'[Orchestrator] Check GitHub Actions runner availability before starting a build. When no suitable runners are
|
|
||||||
available and fallbackProviderStrategy is set, automatically routes to the fallback provider.'
|
|
||||||
runnerCheckLabels:
|
|
||||||
default: ''
|
|
||||||
required: false
|
|
||||||
description:
|
|
||||||
'[Orchestrator] Comma-separated runner labels to filter when checking availability (e.g. self-hosted,linux).
|
|
||||||
When empty, checks all runners in the repository.'
|
|
||||||
runnerCheckMinAvailable:
|
|
||||||
default: '1'
|
|
||||||
required: false
|
|
||||||
description:
|
|
||||||
'[Orchestrator] Minimum number of idle runners required for the primary provider. If fewer are available,
|
|
||||||
routes to fallbackProviderStrategy.'
|
|
||||||
retryOnFallback:
|
|
||||||
default: 'false'
|
|
||||||
required: false
|
|
||||||
description:
|
|
||||||
'[Orchestrator] When true and fallbackProviderStrategy is set, automatically retry the build on the fallback
|
|
||||||
provider if the primary provider fails. Useful for long builds where transient cloud failures are common.'
|
|
||||||
providerInitTimeout:
|
|
||||||
default: '0'
|
|
||||||
required: false
|
|
||||||
description:
|
|
||||||
'[Orchestrator] Maximum seconds to wait for the primary provider to initialize (setupWorkflow). If exceeded
|
|
||||||
and fallbackProviderStrategy is set, switches to the fallback. Set to 0 to disable (default).'
|
|
||||||
resourceTracking:
|
resourceTracking:
|
||||||
default: 'false'
|
default: 'false'
|
||||||
required: false
|
required: false
|
||||||
@@ -305,6 +269,28 @@ inputs:
|
|||||||
default: 'false'
|
default: 'false'
|
||||||
required: false
|
required: false
|
||||||
description: 'Skip the activation/deactivation of Unity. This assumes Unity is already activated.'
|
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:
|
cloneDepth:
|
||||||
default: '50'
|
default: '50'
|
||||||
required: false
|
required: false
|
||||||
@@ -328,6 +314,8 @@ outputs:
|
|||||||
'Returns the exit code from the build scripts. This code is 0 if the build was successful. If there was an error
|
'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
|
during activation, the code is from the activation step. If activation is successful, the code is from the project
|
||||||
build step.'
|
build step.'
|
||||||
|
artifactManifestPath:
|
||||||
|
description: 'Path to the generated artifact manifest JSON file'
|
||||||
branding:
|
branding:
|
||||||
icon: 'box'
|
icon: 'box'
|
||||||
color: 'gray-dark'
|
color: 'gray-dark'
|
||||||
|
|||||||
+619
-255
@@ -34,10 +34,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||||
const core = __importStar(__nccwpck_require__(42186));
|
const core = __importStar(__nccwpck_require__(42186));
|
||||||
|
const node_path_1 = __importDefault(__nccwpck_require__(49411));
|
||||||
const model_1 = __nccwpck_require__(41359);
|
const model_1 = __nccwpck_require__(41359);
|
||||||
const cli_1 = __nccwpck_require__(55651);
|
const cli_1 = __nccwpck_require__(55651);
|
||||||
const mac_builder_1 = __importDefault(__nccwpck_require__(39364));
|
const mac_builder_1 = __importDefault(__nccwpck_require__(39364));
|
||||||
const platform_setup_1 = __importDefault(__nccwpck_require__(64423));
|
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() {
|
async function runMain() {
|
||||||
try {
|
try {
|
||||||
if (cli_1.Cli.InitCliMode()) {
|
if (cli_1.Cli.InitCliMode()) {
|
||||||
@@ -70,6 +74,44 @@ async function runMain() {
|
|||||||
await model_1.Output.setBuildVersion(buildParameters.buildVersion);
|
await model_1.Output.setBuildVersion(buildParameters.buildVersion);
|
||||||
await model_1.Output.setAndroidVersionCode(buildParameters.androidVersionCode);
|
await model_1.Output.setAndroidVersionCode(buildParameters.androidVersionCode);
|
||||||
await model_1.Output.setEngineExitCode(exitCode);
|
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) {
|
if (exitCode !== 0) {
|
||||||
core.setFailed(`Build failed with exit code ${exitCode}`);
|
core.setFailed(`Build failed with exit code ${exitCode}`);
|
||||||
}
|
}
|
||||||
@@ -327,12 +369,6 @@ class BuildParameters {
|
|||||||
containerRegistryRepository: input_1.default.containerRegistryRepository,
|
containerRegistryRepository: input_1.default.containerRegistryRepository,
|
||||||
containerRegistryImageVersion: input_1.default.containerRegistryImageVersion,
|
containerRegistryImageVersion: input_1.default.containerRegistryImageVersion,
|
||||||
providerStrategy: orchestrator_options_1.default.providerStrategy,
|
providerStrategy: orchestrator_options_1.default.providerStrategy,
|
||||||
fallbackProviderStrategy: orchestrator_options_1.default.fallbackProviderStrategy,
|
|
||||||
runnerCheckEnabled: orchestrator_options_1.default.runnerCheckEnabled,
|
|
||||||
runnerCheckLabels: orchestrator_options_1.default.runnerCheckLabels,
|
|
||||||
runnerCheckMinAvailable: orchestrator_options_1.default.runnerCheckMinAvailable,
|
|
||||||
retryOnFallback: orchestrator_options_1.default.retryOnFallback,
|
|
||||||
providerInitTimeout: orchestrator_options_1.default.providerInitTimeout,
|
|
||||||
buildPlatform: orchestrator_options_1.default.buildPlatform,
|
buildPlatform: orchestrator_options_1.default.buildPlatform,
|
||||||
kubeConfig: orchestrator_options_1.default.kubeConfig,
|
kubeConfig: orchestrator_options_1.default.kubeConfig,
|
||||||
containerMemory: orchestrator_options_1.default.containerMemory,
|
containerMemory: orchestrator_options_1.default.containerMemory,
|
||||||
@@ -381,6 +417,12 @@ class BuildParameters {
|
|||||||
cacheUnityInstallationOnMac: input_1.default.cacheUnityInstallationOnMac,
|
cacheUnityInstallationOnMac: input_1.default.cacheUnityInstallationOnMac,
|
||||||
unityHubVersionOnMac: input_1.default.unityHubVersionOnMac,
|
unityHubVersionOnMac: input_1.default.unityHubVersionOnMac,
|
||||||
dockerWorkspacePath: input_1.default.dockerWorkspacePath,
|
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) {
|
static parseBuildFile(filename, platform, androidExportType) {
|
||||||
@@ -1829,6 +1871,24 @@ class Input {
|
|||||||
static get containerRegistryImageVersion() {
|
static get containerRegistryImageVersion() {
|
||||||
return Input.getInput('containerRegistryImageVersion') ?? '3';
|
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() {
|
static get skipActivation() {
|
||||||
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
|
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
|
||||||
}
|
}
|
||||||
@@ -2210,25 +2270,6 @@ class OrchestratorOptions {
|
|||||||
}
|
}
|
||||||
return provider || 'local';
|
return provider || 'local';
|
||||||
}
|
}
|
||||||
static get fallbackProviderStrategy() {
|
|
||||||
return OrchestratorOptions.getInput('fallbackProviderStrategy') || '';
|
|
||||||
}
|
|
||||||
static get runnerCheckEnabled() {
|
|
||||||
return OrchestratorOptions.getInput('runnerCheckEnabled') === 'true';
|
|
||||||
}
|
|
||||||
static get runnerCheckLabels() {
|
|
||||||
const labels = OrchestratorOptions.getInput('runnerCheckLabels');
|
|
||||||
return labels ? labels.split(',').map((l) => l.trim()) : [];
|
|
||||||
}
|
|
||||||
static get runnerCheckMinAvailable() {
|
|
||||||
return Number(OrchestratorOptions.getInput('runnerCheckMinAvailable')) || 1;
|
|
||||||
}
|
|
||||||
static get retryOnFallback() {
|
|
||||||
return OrchestratorOptions.getInput('retryOnFallback') === 'true';
|
|
||||||
}
|
|
||||||
static get providerInitTimeout() {
|
|
||||||
return Number(OrchestratorOptions.getInput('providerInitTimeout')) || 0;
|
|
||||||
}
|
|
||||||
static get containerCpu() {
|
static get containerCpu() {
|
||||||
return OrchestratorOptions.getInput('containerCpu') || `1024`;
|
return OrchestratorOptions.getInput('containerCpu') || `1024`;
|
||||||
}
|
}
|
||||||
@@ -2533,7 +2574,6 @@ const follow_log_stream_service_1 = __nccwpck_require__(36149);
|
|||||||
const orchestrator_result_1 = __importDefault(__nccwpck_require__(86819));
|
const orchestrator_result_1 = __importDefault(__nccwpck_require__(86819));
|
||||||
const orchestrator_options_1 = __importDefault(__nccwpck_require__(82473));
|
const orchestrator_options_1 = __importDefault(__nccwpck_require__(82473));
|
||||||
const resource_tracking_1 = __importDefault(__nccwpck_require__(42604));
|
const resource_tracking_1 = __importDefault(__nccwpck_require__(42604));
|
||||||
const runner_availability_service_1 = __nccwpck_require__(18876);
|
|
||||||
class Orchestrator {
|
class Orchestrator {
|
||||||
static get isOrchestratorEnvironment() {
|
static get isOrchestratorEnvironment() {
|
||||||
return process.env[`GITHUB_ACTIONS`] !== `true`;
|
return process.env[`GITHUB_ACTIONS`] !== `true`;
|
||||||
@@ -2570,26 +2610,6 @@ class Orchestrator {
|
|||||||
}
|
}
|
||||||
static async setupSelectedBuildPlatform() {
|
static async setupSelectedBuildPlatform() {
|
||||||
orchestrator_logger_1.default.log(`Orchestrator platform selected ${Orchestrator.buildParameters.providerStrategy}`);
|
orchestrator_logger_1.default.log(`Orchestrator platform selected ${Orchestrator.buildParameters.providerStrategy}`);
|
||||||
// Check runner availability and apply fallback if needed
|
|
||||||
if (Orchestrator.buildParameters.runnerCheckEnabled && Orchestrator.buildParameters.fallbackProviderStrategy) {
|
|
||||||
const owner = orchestrator_options_1.default.githubOwner;
|
|
||||||
const repo = orchestrator_options_1.default.githubRepoName;
|
|
||||||
const token = Orchestrator.buildParameters.gitPrivateToken || process.env.GITHUB_TOKEN || '';
|
|
||||||
orchestrator_logger_1.default.log(`Checking runner availability (labels: [${Orchestrator.buildParameters.runnerCheckLabels.join(', ')}], min: ${Orchestrator.buildParameters.runnerCheckMinAvailable})`);
|
|
||||||
const result = await runner_availability_service_1.RunnerAvailabilityService.checkAvailability(owner, repo, token, Orchestrator.buildParameters.runnerCheckLabels, Orchestrator.buildParameters.runnerCheckMinAvailable);
|
|
||||||
orchestrator_logger_1.default.log(`Runner check: ${result.totalRunners} total, ${result.matchingRunners} matching, ${result.idleRunners} idle — ${result.reason}`);
|
|
||||||
if (result.shouldFallback) {
|
|
||||||
const original = Orchestrator.buildParameters.providerStrategy;
|
|
||||||
const fallback = Orchestrator.buildParameters.fallbackProviderStrategy;
|
|
||||||
orchestrator_logger_1.default.log(`Falling back from '${original}' to '${fallback}' — ${result.reason}`);
|
|
||||||
Orchestrator.buildParameters.providerStrategy = fallback;
|
|
||||||
core.setOutput('providerFallbackUsed', 'true');
|
|
||||||
core.setOutput('providerFallbackReason', result.reason);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
core.setOutput('providerFallbackUsed', 'false');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Detect LocalStack endpoints and handle AWS provider appropriately
|
// Detect LocalStack endpoints and handle AWS provider appropriately
|
||||||
// AWS_FORCE_PROVIDER options:
|
// AWS_FORCE_PROVIDER options:
|
||||||
// - 'aws': Force AWS provider (requires LocalStack Pro with ECS support)
|
// - 'aws': Force AWS provider (requires LocalStack Pro with ECS support)
|
||||||
@@ -2688,33 +2708,13 @@ class Orchestrator {
|
|||||||
if (baseImage.includes(`undefined`)) {
|
if (baseImage.includes(`undefined`)) {
|
||||||
throw new Error(`baseImage is undefined`);
|
throw new Error(`baseImage is undefined`);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
return await Orchestrator.runWithProvider(buildParameters, baseImage);
|
|
||||||
}
|
|
||||||
catch (primaryError) {
|
|
||||||
// Retry on fallback provider if enabled and a fallback is configured
|
|
||||||
const fallback = buildParameters.fallbackProviderStrategy;
|
|
||||||
const alreadyOnFallback = buildParameters.providerStrategy === fallback;
|
|
||||||
if (buildParameters.retryOnFallback && fallback && !alreadyOnFallback) {
|
|
||||||
orchestrator_logger_1.default.log(`Primary provider '${buildParameters.providerStrategy}' failed: ${primaryError.message}`);
|
|
||||||
orchestrator_logger_1.default.log(`Retrying build on fallback provider '${fallback}'...`);
|
|
||||||
buildParameters.providerStrategy = fallback;
|
|
||||||
core.setOutput('providerFallbackUsed', 'true');
|
|
||||||
core.setOutput('providerFallbackReason', `Primary provider failed: ${primaryError.message}`);
|
|
||||||
return await Orchestrator.runWithProvider(buildParameters, baseImage);
|
|
||||||
}
|
|
||||||
throw primaryError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static async runWithProvider(buildParameters, baseImage) {
|
|
||||||
await Orchestrator.setup(buildParameters);
|
await Orchestrator.setup(buildParameters);
|
||||||
// When aws-local mode is enabled, validate AWS CloudFormation templates
|
// When aws-local mode is enabled, validate AWS CloudFormation templates
|
||||||
// This ensures AWS templates are correct even when executing via local-docker
|
// This ensures AWS templates are correct even when executing via local-docker
|
||||||
if (Orchestrator.validateAwsTemplates) {
|
if (Orchestrator.validateAwsTemplates) {
|
||||||
await Orchestrator.validateAwsCloudFormationTemplates();
|
await Orchestrator.validateAwsCloudFormationTemplates();
|
||||||
}
|
}
|
||||||
// Setup workflow with optional init timeout
|
await Orchestrator.Provider.setupWorkflow(Orchestrator.buildParameters.buildGuid, Orchestrator.buildParameters, Orchestrator.buildParameters.branch, Orchestrator.defaultSecrets);
|
||||||
await Orchestrator.setupWorkflowWithTimeout();
|
|
||||||
try {
|
try {
|
||||||
if (buildParameters.maxRetainedWorkspaces > 0) {
|
if (buildParameters.maxRetainedWorkspaces > 0) {
|
||||||
Orchestrator.lockedWorkspace = shared_workspace_locking_1.default.NewWorkspaceName();
|
Orchestrator.lockedWorkspace = shared_workspace_locking_1.default.NewWorkspaceName();
|
||||||
@@ -2764,24 +2764,6 @@ class Orchestrator {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Runs setupWorkflow with an optional timeout. If providerInitTimeout is set and the
|
|
||||||
* provider takes longer than that to initialize, throws an error that triggers
|
|
||||||
* retry-on-fallback (if enabled).
|
|
||||||
*/
|
|
||||||
static async setupWorkflowWithTimeout() {
|
|
||||||
const timeoutSeconds = Orchestrator.buildParameters.providerInitTimeout;
|
|
||||||
const setupPromise = Orchestrator.Provider.setupWorkflow(Orchestrator.buildParameters.buildGuid, Orchestrator.buildParameters, Orchestrator.buildParameters.branch, Orchestrator.defaultSecrets);
|
|
||||||
if (timeoutSeconds <= 0) {
|
|
||||||
await setupPromise;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
orchestrator_logger_1.default.log(`Provider init timeout: ${timeoutSeconds}s`);
|
|
||||||
const timeoutPromise = new Promise((_, reject) => {
|
|
||||||
setTimeout(() => reject(new Error(`Provider initialization timed out after ${timeoutSeconds}s`)), timeoutSeconds * 1000);
|
|
||||||
});
|
|
||||||
await Promise.race([setupPromise, timeoutPromise]);
|
|
||||||
}
|
|
||||||
static async updateStatusWithBuildParameters() {
|
static async updateStatusWithBuildParameters() {
|
||||||
const content = { ...Orchestrator.buildParameters };
|
const content = { ...Orchestrator.buildParameters };
|
||||||
content.gitPrivateToken = ``;
|
content.gitPrivateToken = ``;
|
||||||
@@ -8723,176 +8705,6 @@ class ResourceTracking {
|
|||||||
exports["default"] = ResourceTracking;
|
exports["default"] = ResourceTracking;
|
||||||
|
|
||||||
|
|
||||||
/***/ }),
|
|
||||||
|
|
||||||
/***/ 18876:
|
|
||||||
/***/ (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.RunnerAvailabilityService = void 0;
|
|
||||||
const core_1 = __nccwpck_require__(76762);
|
|
||||||
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
|
|
||||||
/**
|
|
||||||
* Maximum number of pages to fetch when paginating through GitHub API results.
|
|
||||||
* 100 pages * 100 per page = 10,000 runners maximum.
|
|
||||||
*/
|
|
||||||
const MAX_PAGINATION_PAGES = 100;
|
|
||||||
/**
|
|
||||||
* Total timeout in milliseconds for the pagination loop.
|
|
||||||
* Prevents indefinite API calls if GitHub is slow or pagination is unexpectedly deep.
|
|
||||||
*/
|
|
||||||
const PAGINATION_TIMEOUT_MS = 30000;
|
|
||||||
/**
|
|
||||||
* Checks GitHub Actions runner availability to support automatic provider fallback.
|
|
||||||
*
|
|
||||||
* When a user configures `runnerCheckEnabled: true` with a `fallbackProviderStrategy`,
|
|
||||||
* this service queries the GitHub API for runner status before the build starts.
|
|
||||||
* If insufficient runners are available, the orchestrator routes to the fallback provider.
|
|
||||||
*/
|
|
||||||
class RunnerAvailabilityService {
|
|
||||||
/**
|
|
||||||
* Check if enough runners are available to handle the build.
|
|
||||||
*
|
|
||||||
* @param owner - GitHub repository owner
|
|
||||||
* @param repo - GitHub repository name
|
|
||||||
* @param token - GitHub token with repo/actions scope
|
|
||||||
* @param requiredLabels - Labels runners must have (empty = any runner)
|
|
||||||
* @param minAvailable - Minimum idle runners required
|
|
||||||
* @returns RunnerCheckResult with decision and diagnostics
|
|
||||||
*/
|
|
||||||
static async checkAvailability(owner, repo, token, requiredLabels, minAvailable) {
|
|
||||||
if (!token) {
|
|
||||||
return {
|
|
||||||
shouldFallback: false,
|
|
||||||
reason: 'No GitHub token available — skipping runner check',
|
|
||||||
totalRunners: 0,
|
|
||||||
matchingRunners: 0,
|
|
||||||
idleRunners: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const octokit = new core_1.Octokit({ auth: token });
|
|
||||||
// Fetch all runners for the repository
|
|
||||||
const runners = await RunnerAvailabilityService.fetchRunners(octokit, owner, repo);
|
|
||||||
if (runners.length === 0) {
|
|
||||||
return {
|
|
||||||
shouldFallback: true,
|
|
||||||
reason: 'No runners registered for this repository',
|
|
||||||
totalRunners: 0,
|
|
||||||
matchingRunners: 0,
|
|
||||||
idleRunners: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Filter by required labels
|
|
||||||
const matching = RunnerAvailabilityService.filterByLabels(runners, requiredLabels);
|
|
||||||
// Count idle (online + not busy)
|
|
||||||
const idle = matching.filter((r) => r.status === 'online' && !r.busy);
|
|
||||||
const result = {
|
|
||||||
shouldFallback: idle.length < minAvailable,
|
|
||||||
reason: idle.length >= minAvailable
|
|
||||||
? `${idle.length} idle runner(s) available (need ${minAvailable})`
|
|
||||||
: `Only ${idle.length} idle runner(s) available, need ${minAvailable}`,
|
|
||||||
totalRunners: runners.length,
|
|
||||||
matchingRunners: matching.length,
|
|
||||||
idleRunners: idle.length,
|
|
||||||
};
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
// If the API call fails (permissions, rate limit, etc.), don't block the build
|
|
||||||
orchestrator_logger_1.default.log(`Runner availability check failed: ${error.message}`);
|
|
||||||
return {
|
|
||||||
shouldFallback: false,
|
|
||||||
reason: `Runner check failed (${error.message}) — proceeding with primary provider`,
|
|
||||||
totalRunners: 0,
|
|
||||||
matchingRunners: 0,
|
|
||||||
idleRunners: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Fetch all runners for a repository, handling pagination.
|
|
||||||
*
|
|
||||||
* Includes defensive limits:
|
|
||||||
* - Maximum page count (MAX_PAGINATION_PAGES) to prevent infinite loops
|
|
||||||
* - Total timeout (PAGINATION_TIMEOUT_MS) to prevent indefinite API calls
|
|
||||||
* - Rate-limit detection (HTTP 403/429 with X-RateLimit-Remaining header)
|
|
||||||
*/
|
|
||||||
static async fetchRunners(octokit, owner, repo) {
|
|
||||||
const allRunners = [];
|
|
||||||
let page = 1;
|
|
||||||
const perPage = 100;
|
|
||||||
const startTime = Date.now();
|
|
||||||
while (page <= MAX_PAGINATION_PAGES) {
|
|
||||||
// Check total timeout
|
|
||||||
if (Date.now() - startTime > PAGINATION_TIMEOUT_MS) {
|
|
||||||
orchestrator_logger_1.default.logWarning(`[RunnerAvailability] Pagination timeout reached after ${page - 1} pages and ${Date.now() - startTime}ms. ` +
|
|
||||||
`Using ${allRunners.length} runners found so far.`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await octokit.request('GET /repos/{owner}/{repo}/actions/runners', {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
per_page: perPage,
|
|
||||||
page,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (requestError) {
|
|
||||||
// Octokit throws for non-2xx responses. Check if this is a rate limit error.
|
|
||||||
const status = requestError.status ?? requestError.response?.status;
|
|
||||||
if (status === 403 || status === 429) {
|
|
||||||
const resetTime = requestError.response?.headers?.['x-ratelimit-reset'] ?? requestError.headers?.['x-ratelimit-reset'];
|
|
||||||
const resetMessage = resetTime
|
|
||||||
? ` Resets at ${new Date(Number.parseInt(String(resetTime), 10) * 1000).toISOString()}`
|
|
||||||
: '';
|
|
||||||
orchestrator_logger_1.default.logWarning(`[RunnerAvailability] GitHub API rate limit reached (HTTP ${status}).${resetMessage} ` +
|
|
||||||
`Using ${allRunners.length} runners found so far.`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Re-throw non-rate-limit errors to be handled by the outer catch
|
|
||||||
throw requestError;
|
|
||||||
}
|
|
||||||
const runners = (response.data.runners || []);
|
|
||||||
allRunners.push(...runners);
|
|
||||||
if (runners.length < perPage)
|
|
||||||
break;
|
|
||||||
page++;
|
|
||||||
}
|
|
||||||
if (page > MAX_PAGINATION_PAGES) {
|
|
||||||
orchestrator_logger_1.default.logWarning(`[RunnerAvailability] Maximum pagination limit reached (${MAX_PAGINATION_PAGES} pages). ` +
|
|
||||||
`Using ${allRunners.length} runners found so far.`);
|
|
||||||
}
|
|
||||||
if (allRunners.length === 0) {
|
|
||||||
orchestrator_logger_1.default.log('[RunnerAvailability] No runners found. Possible causes: ' +
|
|
||||||
'wrong token permissions (needs repo or actions scope), ' +
|
|
||||||
'no self-hosted runners registered, ' +
|
|
||||||
'or runners are registered at the organization level instead of the repository.');
|
|
||||||
}
|
|
||||||
return allRunners;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Filter runners by required labels. A runner matches if it has ALL required labels.
|
|
||||||
* If requiredLabels is empty, all runners match.
|
|
||||||
*/
|
|
||||||
static filterByLabels(runners, requiredLabels) {
|
|
||||||
if (requiredLabels.length === 0)
|
|
||||||
return runners;
|
|
||||||
return runners.filter((runner) => {
|
|
||||||
const runnerLabelNames = runner.labels.map((l) => l.name.toLowerCase());
|
|
||||||
return requiredLabels.every((required) => runnerLabelNames.includes(required.toLowerCase()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exports.RunnerAvailabilityService = RunnerAvailabilityService;
|
|
||||||
|
|
||||||
|
|
||||||
/***/ }),
|
/***/ }),
|
||||||
|
|
||||||
/***/ 54222:
|
/***/ 54222:
|
||||||
@@ -9886,6 +9698,550 @@ class ContainerHookService {
|
|||||||
exports.ContainerHookService = 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:
|
/***/ 23451:
|
||||||
@@ -358787,6 +359143,14 @@ try {
|
|||||||
} catch (er) {}
|
} catch (er) {}
|
||||||
|
|
||||||
|
|
||||||
|
/***/ }),
|
||||||
|
|
||||||
|
/***/ 89346:
|
||||||
|
/***/ ((module) => {
|
||||||
|
|
||||||
|
module.exports = eval("require")("@actions/artifact");
|
||||||
|
|
||||||
|
|
||||||
/***/ }),
|
/***/ }),
|
||||||
|
|
||||||
/***/ 71269:
|
/***/ 71269:
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,8 +1,12 @@
|
|||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
|
import path from 'node:path';
|
||||||
import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output } from './model';
|
import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output } from './model';
|
||||||
import { Cli } from './model/cli/cli';
|
import { Cli } from './model/cli/cli';
|
||||||
import MacBuilder from './model/mac-builder';
|
import MacBuilder from './model/mac-builder';
|
||||||
import PlatformSetup from './model/platform-setup';
|
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() {
|
async function runMain() {
|
||||||
try {
|
try {
|
||||||
@@ -42,6 +46,64 @@ async function runMain() {
|
|||||||
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
|
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
|
||||||
await Output.setEngineExitCode(exitCode);
|
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) {
|
if (exitCode !== 0) {
|
||||||
core.setFailed(`Build failed with exit code ${exitCode}`);
|
core.setFailed(`Build failed with exit code ${exitCode}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,12 +54,6 @@ class BuildParameters {
|
|||||||
public sshAgent!: string;
|
public sshAgent!: string;
|
||||||
public sshPublicKeysDirectoryPath!: string;
|
public sshPublicKeysDirectoryPath!: string;
|
||||||
public providerStrategy!: string;
|
public providerStrategy!: string;
|
||||||
public fallbackProviderStrategy!: string;
|
|
||||||
public runnerCheckEnabled!: boolean;
|
|
||||||
public runnerCheckLabels!: string[];
|
|
||||||
public runnerCheckMinAvailable!: number;
|
|
||||||
public retryOnFallback!: boolean;
|
|
||||||
public providerInitTimeout!: number;
|
|
||||||
public gitPrivateToken!: string;
|
public gitPrivateToken!: string;
|
||||||
public awsStackName!: string;
|
public awsStackName!: string;
|
||||||
public awsEndpoint?: string;
|
public awsEndpoint?: string;
|
||||||
@@ -112,6 +106,12 @@ class BuildParameters {
|
|||||||
public cacheUnityInstallationOnMac!: boolean;
|
public cacheUnityInstallationOnMac!: boolean;
|
||||||
public unityHubVersionOnMac!: string;
|
public unityHubVersionOnMac!: string;
|
||||||
public dockerWorkspacePath!: 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) {
|
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
|
||||||
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
|
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
|
||||||
@@ -200,12 +200,6 @@ class BuildParameters {
|
|||||||
containerRegistryRepository: Input.containerRegistryRepository,
|
containerRegistryRepository: Input.containerRegistryRepository,
|
||||||
containerRegistryImageVersion: Input.containerRegistryImageVersion,
|
containerRegistryImageVersion: Input.containerRegistryImageVersion,
|
||||||
providerStrategy: OrchestratorOptions.providerStrategy,
|
providerStrategy: OrchestratorOptions.providerStrategy,
|
||||||
fallbackProviderStrategy: OrchestratorOptions.fallbackProviderStrategy,
|
|
||||||
runnerCheckEnabled: OrchestratorOptions.runnerCheckEnabled,
|
|
||||||
runnerCheckLabels: OrchestratorOptions.runnerCheckLabels,
|
|
||||||
runnerCheckMinAvailable: OrchestratorOptions.runnerCheckMinAvailable,
|
|
||||||
retryOnFallback: OrchestratorOptions.retryOnFallback,
|
|
||||||
providerInitTimeout: OrchestratorOptions.providerInitTimeout,
|
|
||||||
buildPlatform: OrchestratorOptions.buildPlatform,
|
buildPlatform: OrchestratorOptions.buildPlatform,
|
||||||
kubeConfig: OrchestratorOptions.kubeConfig,
|
kubeConfig: OrchestratorOptions.kubeConfig,
|
||||||
containerMemory: OrchestratorOptions.containerMemory,
|
containerMemory: OrchestratorOptions.containerMemory,
|
||||||
@@ -254,6 +248,12 @@ class BuildParameters {
|
|||||||
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
|
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
|
||||||
unityHubVersionOnMac: Input.unityHubVersionOnMac,
|
unityHubVersionOnMac: Input.unityHubVersionOnMac,
|
||||||
dockerWorkspacePath: Input.dockerWorkspacePath,
|
dockerWorkspacePath: Input.dockerWorkspacePath,
|
||||||
|
artifactOutputTypes: Input.artifactOutputTypes,
|
||||||
|
artifactUploadTarget: Input.artifactUploadTarget,
|
||||||
|
artifactUploadPath: Input.artifactUploadPath,
|
||||||
|
artifactCompression: Input.artifactCompression,
|
||||||
|
artifactRetentionDays: Input.artifactRetentionDays,
|
||||||
|
artifactCustomTypes: Input.artifactCustomTypes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,30 @@ class Input {
|
|||||||
return Input.getInput('containerRegistryImageVersion') ?? '3';
|
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 {
|
static get skipActivation(): string {
|
||||||
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
|
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,32 +138,6 @@ class OrchestratorOptions {
|
|||||||
return provider || 'local';
|
return provider || 'local';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get fallbackProviderStrategy(): string {
|
|
||||||
return OrchestratorOptions.getInput('fallbackProviderStrategy') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get runnerCheckEnabled(): boolean {
|
|
||||||
return OrchestratorOptions.getInput('runnerCheckEnabled') === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get runnerCheckLabels(): string[] {
|
|
||||||
const labels = OrchestratorOptions.getInput('runnerCheckLabels');
|
|
||||||
|
|
||||||
return labels ? labels.split(',').map((l) => l.trim()) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
static get runnerCheckMinAvailable(): number {
|
|
||||||
return Number(OrchestratorOptions.getInput('runnerCheckMinAvailable')) || 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get retryOnFallback(): boolean {
|
|
||||||
return OrchestratorOptions.getInput('retryOnFallback') === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get providerInitTimeout(): number {
|
|
||||||
return Number(OrchestratorOptions.getInput('providerInitTimeout')) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get containerCpu(): string {
|
static get containerCpu(): string {
|
||||||
return OrchestratorOptions.getInput('containerCpu') || `1024`;
|
return OrchestratorOptions.getInput('containerCpu') || `1024`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { FollowLogStreamService } from './services/core/follow-log-stream-servic
|
|||||||
import OrchestratorResult from './services/core/orchestrator-result';
|
import OrchestratorResult from './services/core/orchestrator-result';
|
||||||
import OrchestratorOptions from './options/orchestrator-options';
|
import OrchestratorOptions from './options/orchestrator-options';
|
||||||
import ResourceTracking from './services/core/resource-tracking';
|
import ResourceTracking from './services/core/resource-tracking';
|
||||||
import { RunnerAvailabilityService } from './services/core/runner-availability-service';
|
|
||||||
|
|
||||||
class Orchestrator {
|
class Orchestrator {
|
||||||
public static Provider: ProviderInterface;
|
public static Provider: ProviderInterface;
|
||||||
@@ -77,42 +76,6 @@ class Orchestrator {
|
|||||||
private static async setupSelectedBuildPlatform() {
|
private static async setupSelectedBuildPlatform() {
|
||||||
OrchestratorLogger.log(`Orchestrator platform selected ${Orchestrator.buildParameters.providerStrategy}`);
|
OrchestratorLogger.log(`Orchestrator platform selected ${Orchestrator.buildParameters.providerStrategy}`);
|
||||||
|
|
||||||
// Check runner availability and apply fallback if needed
|
|
||||||
if (Orchestrator.buildParameters.runnerCheckEnabled && Orchestrator.buildParameters.fallbackProviderStrategy) {
|
|
||||||
const owner = OrchestratorOptions.githubOwner;
|
|
||||||
const repo = OrchestratorOptions.githubRepoName;
|
|
||||||
const token = Orchestrator.buildParameters.gitPrivateToken || process.env.GITHUB_TOKEN || '';
|
|
||||||
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Checking runner availability (labels: [${Orchestrator.buildParameters.runnerCheckLabels.join(', ')}], min: ${
|
|
||||||
Orchestrator.buildParameters.runnerCheckMinAvailable
|
|
||||||
})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability(
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
token,
|
|
||||||
Orchestrator.buildParameters.runnerCheckLabels,
|
|
||||||
Orchestrator.buildParameters.runnerCheckMinAvailable,
|
|
||||||
);
|
|
||||||
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Runner check: ${result.totalRunners} total, ${result.matchingRunners} matching, ${result.idleRunners} idle — ${result.reason}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.shouldFallback) {
|
|
||||||
const original = Orchestrator.buildParameters.providerStrategy;
|
|
||||||
const fallback = Orchestrator.buildParameters.fallbackProviderStrategy;
|
|
||||||
OrchestratorLogger.log(`Falling back from '${original}' to '${fallback}' — ${result.reason}`);
|
|
||||||
Orchestrator.buildParameters.providerStrategy = fallback;
|
|
||||||
core.setOutput('providerFallbackUsed', 'true');
|
|
||||||
core.setOutput('providerFallbackReason', result.reason);
|
|
||||||
} else {
|
|
||||||
core.setOutput('providerFallbackUsed', 'false');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect LocalStack endpoints and handle AWS provider appropriately
|
// Detect LocalStack endpoints and handle AWS provider appropriately
|
||||||
// AWS_FORCE_PROVIDER options:
|
// AWS_FORCE_PROVIDER options:
|
||||||
// - 'aws': Force AWS provider (requires LocalStack Pro with ECS support)
|
// - 'aws': Force AWS provider (requires LocalStack Pro with ECS support)
|
||||||
@@ -219,30 +182,6 @@ class Orchestrator {
|
|||||||
if (baseImage.includes(`undefined`)) {
|
if (baseImage.includes(`undefined`)) {
|
||||||
throw new Error(`baseImage is undefined`);
|
throw new Error(`baseImage is undefined`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
return await Orchestrator.runWithProvider(buildParameters, baseImage);
|
|
||||||
} catch (primaryError: any) {
|
|
||||||
// Retry on fallback provider if enabled and a fallback is configured
|
|
||||||
const fallback = buildParameters.fallbackProviderStrategy;
|
|
||||||
const alreadyOnFallback = buildParameters.providerStrategy === fallback;
|
|
||||||
if (buildParameters.retryOnFallback && fallback && !alreadyOnFallback) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Primary provider '${buildParameters.providerStrategy}' failed: ${primaryError.message}`,
|
|
||||||
);
|
|
||||||
OrchestratorLogger.log(`Retrying build on fallback provider '${fallback}'...`);
|
|
||||||
buildParameters.providerStrategy = fallback;
|
|
||||||
core.setOutput('providerFallbackUsed', 'true');
|
|
||||||
core.setOutput('providerFallbackReason', `Primary provider failed: ${primaryError.message}`);
|
|
||||||
|
|
||||||
return await Orchestrator.runWithProvider(buildParameters, baseImage);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw primaryError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async runWithProvider(buildParameters: BuildParameters, baseImage: string) {
|
|
||||||
await Orchestrator.setup(buildParameters);
|
await Orchestrator.setup(buildParameters);
|
||||||
|
|
||||||
// When aws-local mode is enabled, validate AWS CloudFormation templates
|
// When aws-local mode is enabled, validate AWS CloudFormation templates
|
||||||
@@ -250,10 +189,12 @@ class Orchestrator {
|
|||||||
if (Orchestrator.validateAwsTemplates) {
|
if (Orchestrator.validateAwsTemplates) {
|
||||||
await Orchestrator.validateAwsCloudFormationTemplates();
|
await Orchestrator.validateAwsCloudFormationTemplates();
|
||||||
}
|
}
|
||||||
|
await Orchestrator.Provider.setupWorkflow(
|
||||||
// Setup workflow with optional init timeout
|
Orchestrator.buildParameters.buildGuid,
|
||||||
await Orchestrator.setupWorkflowWithTimeout();
|
Orchestrator.buildParameters,
|
||||||
|
Orchestrator.buildParameters.branch,
|
||||||
|
Orchestrator.defaultSecrets,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
if (buildParameters.maxRetainedWorkspaces > 0) {
|
if (buildParameters.maxRetainedWorkspaces > 0) {
|
||||||
Orchestrator.lockedWorkspace = SharedWorkspaceLocking.NewWorkspaceName();
|
Orchestrator.lockedWorkspace = SharedWorkspaceLocking.NewWorkspaceName();
|
||||||
@@ -334,39 +275,6 @@ class Orchestrator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs setupWorkflow with an optional timeout. If providerInitTimeout is set and the
|
|
||||||
* provider takes longer than that to initialize, throws an error that triggers
|
|
||||||
* retry-on-fallback (if enabled).
|
|
||||||
*/
|
|
||||||
private static async setupWorkflowWithTimeout() {
|
|
||||||
const timeoutSeconds = Orchestrator.buildParameters.providerInitTimeout;
|
|
||||||
|
|
||||||
const setupPromise = Orchestrator.Provider.setupWorkflow(
|
|
||||||
Orchestrator.buildParameters.buildGuid,
|
|
||||||
Orchestrator.buildParameters,
|
|
||||||
Orchestrator.buildParameters.branch,
|
|
||||||
Orchestrator.defaultSecrets,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (timeoutSeconds <= 0) {
|
|
||||||
await setupPromise;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Provider init timeout: ${timeoutSeconds}s`);
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
||||||
setTimeout(
|
|
||||||
() => reject(new Error(`Provider initialization timed out after ${timeoutSeconds}s`)),
|
|
||||||
timeoutSeconds * 1000,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.race([setupPromise, timeoutPromise]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async updateStatusWithBuildParameters() {
|
private static async updateStatusWithBuildParameters() {
|
||||||
const content = { ...Orchestrator.buildParameters };
|
const content = { ...Orchestrator.buildParameters };
|
||||||
content.gitPrivateToken = ``;
|
content.gitPrivateToken = ``;
|
||||||
|
|||||||
@@ -1,318 +0,0 @@
|
|||||||
import { RunnerAvailabilityService } from './runner-availability-service';
|
|
||||||
|
|
||||||
// Mock @octokit/core
|
|
||||||
jest.mock('@octokit/core', () => ({
|
|
||||||
Octokit: jest.fn().mockImplementation(() => ({
|
|
||||||
request: jest.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('./orchestrator-logger', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: {
|
|
||||||
log: jest.fn(),
|
|
||||||
logWarning: jest.fn(),
|
|
||||||
error: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { Octokit } from '@octokit/core';
|
|
||||||
|
|
||||||
const MockedOctokit = Octokit as jest.MockedClass<typeof Octokit>;
|
|
||||||
|
|
||||||
function createMockRunners(runners: Array<{ name: string; status: string; busy: boolean; labels: string[] }>) {
|
|
||||||
return runners.map((r, i) => ({
|
|
||||||
id: i + 1,
|
|
||||||
name: r.name,
|
|
||||||
status: r.status,
|
|
||||||
busy: r.busy,
|
|
||||||
labels: r.labels.map((l) => ({ name: l })),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RunnerAvailabilityService', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('checkAvailability', () => {
|
|
||||||
it('should skip check and not fallback when no token is provided', async () => {
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', '', [], 1);
|
|
||||||
expect(result.shouldFallback).toBe(false);
|
|
||||||
expect(result.reason).toContain('No GitHub token');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fallback when no runners are registered', async () => {
|
|
||||||
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners: [] } });
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
|
||||||
expect(result.shouldFallback).toBe(true);
|
|
||||||
expect(result.reason).toContain('No runners registered');
|
|
||||||
expect(result.totalRunners).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not fallback when enough idle runners are available', async () => {
|
|
||||||
const runners = createMockRunners([
|
|
||||||
{ name: 'runner-1', status: 'online', busy: false, labels: ['self-hosted', 'linux'] },
|
|
||||||
{ name: 'runner-2', status: 'online', busy: false, labels: ['self-hosted', 'linux'] },
|
|
||||||
]);
|
|
||||||
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
|
||||||
expect(result.shouldFallback).toBe(false);
|
|
||||||
expect(result.idleRunners).toBe(2);
|
|
||||||
expect(result.totalRunners).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fallback when all runners are busy', async () => {
|
|
||||||
const runners = createMockRunners([
|
|
||||||
{ name: 'runner-1', status: 'online', busy: true, labels: ['self-hosted'] },
|
|
||||||
{ name: 'runner-2', status: 'online', busy: true, labels: ['self-hosted'] },
|
|
||||||
]);
|
|
||||||
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
|
||||||
expect(result.shouldFallback).toBe(true);
|
|
||||||
expect(result.idleRunners).toBe(0);
|
|
||||||
expect(result.matchingRunners).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fallback when all runners are offline', async () => {
|
|
||||||
const runners = createMockRunners([
|
|
||||||
{ name: 'runner-1', status: 'offline', busy: false, labels: ['self-hosted'] },
|
|
||||||
]);
|
|
||||||
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
|
||||||
expect(result.shouldFallback).toBe(true);
|
|
||||||
expect(result.idleRunners).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter runners by required labels', async () => {
|
|
||||||
const runners = createMockRunners([
|
|
||||||
{ name: 'linux-runner', status: 'online', busy: false, labels: ['self-hosted', 'linux'] },
|
|
||||||
{ name: 'windows-runner', status: 'online', busy: false, labels: ['self-hosted', 'windows'] },
|
|
||||||
]);
|
|
||||||
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability(
|
|
||||||
'owner',
|
|
||||||
'repo',
|
|
||||||
'token',
|
|
||||||
['self-hosted', 'linux'],
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.shouldFallback).toBe(false);
|
|
||||||
expect(result.matchingRunners).toBe(1);
|
|
||||||
expect(result.idleRunners).toBe(1);
|
|
||||||
expect(result.totalRunners).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fallback when no runners match required labels', async () => {
|
|
||||||
const runners = createMockRunners([
|
|
||||||
{ name: 'windows-runner', status: 'online', busy: false, labels: ['self-hosted', 'windows'] },
|
|
||||||
]);
|
|
||||||
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability(
|
|
||||||
'owner',
|
|
||||||
'repo',
|
|
||||||
'token',
|
|
||||||
['self-hosted', 'linux'],
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.shouldFallback).toBe(true);
|
|
||||||
expect(result.matchingRunners).toBe(0);
|
|
||||||
expect(result.idleRunners).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should respect minAvailable threshold', async () => {
|
|
||||||
const runners = createMockRunners([{ name: 'runner-1', status: 'online', busy: false, labels: ['self-hosted'] }]);
|
|
||||||
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
// Need 2, have 1 — should fallback
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 2);
|
|
||||||
expect(result.shouldFallback).toBe(true);
|
|
||||||
expect(result.idleRunners).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be case-insensitive for label matching', async () => {
|
|
||||||
const runners = createMockRunners([
|
|
||||||
{ name: 'runner-1', status: 'online', busy: false, labels: ['Self-Hosted', 'Linux'] },
|
|
||||||
]);
|
|
||||||
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability(
|
|
||||||
'owner',
|
|
||||||
'repo',
|
|
||||||
'token',
|
|
||||||
['self-hosted', 'linux'],
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
expect(result.shouldFallback).toBe(false);
|
|
||||||
expect(result.matchingRunners).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not fallback on API error (fail-open)', async () => {
|
|
||||||
const mockRequest = jest.fn().mockRejectedValue(new Error('403 Forbidden'));
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
|
||||||
expect(result.shouldFallback).toBe(false);
|
|
||||||
expect(result.reason).toContain('Runner check failed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should count only online+idle runners', async () => {
|
|
||||||
const runners = createMockRunners([
|
|
||||||
{ name: 'idle', status: 'online', busy: false, labels: ['self-hosted'] },
|
|
||||||
{ name: 'busy', status: 'online', busy: true, labels: ['self-hosted'] },
|
|
||||||
{ name: 'offline', status: 'offline', busy: false, labels: ['self-hosted'] },
|
|
||||||
]);
|
|
||||||
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
|
||||||
expect(result.shouldFallback).toBe(false);
|
|
||||||
expect(result.totalRunners).toBe(3);
|
|
||||||
expect(result.matchingRunners).toBe(3);
|
|
||||||
expect(result.idleRunners).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('pagination limits', () => {
|
|
||||||
it('should stop paginating after reaching the page limit', async () => {
|
|
||||||
// Return full pages (100 runners each) to force continued pagination
|
|
||||||
let callCount = 0;
|
|
||||||
const mockRequest = jest.fn().mockImplementation(() => {
|
|
||||||
callCount++;
|
|
||||||
const runners = createMockRunners(
|
|
||||||
Array.from({ length: 100 }, (_, i) => ({
|
|
||||||
name: `runner-${callCount}-${i}`,
|
|
||||||
status: 'online' as const,
|
|
||||||
busy: false,
|
|
||||||
labels: ['self-hosted'],
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Promise.resolve({ status: 200, data: { runners } });
|
|
||||||
});
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
|
||||||
|
|
||||||
// Should have called at most 100 pages (the MAX_PAGINATION_PAGES limit)
|
|
||||||
expect(mockRequest).toHaveBeenCalledTimes(100);
|
|
||||||
// Should still have runners from the pages it did fetch
|
|
||||||
expect(result.totalRunners).toBe(10000);
|
|
||||||
expect(result.shouldFallback).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should stop paginating on rate limit (HTTP 403)', async () => {
|
|
||||||
let callCount = 0;
|
|
||||||
const mockRequest = jest.fn().mockImplementation(() => {
|
|
||||||
callCount++;
|
|
||||||
if (callCount === 2) {
|
|
||||||
// Octokit throws for non-2xx responses
|
|
||||||
const error: any = new Error('API rate limit exceeded');
|
|
||||||
error.status = 403;
|
|
||||||
error.response = {
|
|
||||||
status: 403,
|
|
||||||
headers: { 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 3600) },
|
|
||||||
};
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
const runners = createMockRunners(
|
|
||||||
Array.from({ length: 100 }, (_, i) => ({
|
|
||||||
name: `runner-${i}`,
|
|
||||||
status: 'online' as const,
|
|
||||||
busy: false,
|
|
||||||
labels: ['self-hosted'],
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Promise.resolve({ status: 200, data: { runners } });
|
|
||||||
});
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
|
||||||
|
|
||||||
// Should have stopped at page 2 (rate limited)
|
|
||||||
expect(mockRequest).toHaveBeenCalledTimes(2);
|
|
||||||
// Should use the 100 runners from the first page
|
|
||||||
expect(result.totalRunners).toBe(100);
|
|
||||||
expect(result.shouldFallback).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should stop paginating on rate limit (HTTP 429)', async () => {
|
|
||||||
let callCount = 0;
|
|
||||||
const mockRequest = jest.fn().mockImplementation(() => {
|
|
||||||
callCount++;
|
|
||||||
if (callCount === 1) {
|
|
||||||
// Octokit throws for non-2xx responses
|
|
||||||
const error: any = new Error('Too Many Requests');
|
|
||||||
error.status = 429;
|
|
||||||
error.response = { status: 429, headers: {} };
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve({ status: 200, data: { runners: [] } });
|
|
||||||
});
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
|
||||||
|
|
||||||
// Should have stopped at first page (rate limited immediately)
|
|
||||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
|
||||||
// No runners found — should fallback
|
|
||||||
expect(result.totalRunners).toBe(0);
|
|
||||||
expect(result.shouldFallback).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle pagination timeout gracefully', async () => {
|
|
||||||
// Mock Date.now to simulate timeout
|
|
||||||
const originalDateNow = Date.now;
|
|
||||||
let callCount = 0;
|
|
||||||
|
|
||||||
const mockRequest = jest.fn().mockImplementation(() => {
|
|
||||||
callCount++;
|
|
||||||
// After first call, advance time past the timeout
|
|
||||||
if (callCount >= 2) {
|
|
||||||
Date.now = jest.fn(() => originalDateNow() + 31_000);
|
|
||||||
}
|
|
||||||
const runners = createMockRunners(
|
|
||||||
Array.from({ length: 100 }, (_, i) => ({
|
|
||||||
name: `runner-${callCount}-${i}`,
|
|
||||||
status: 'online' as const,
|
|
||||||
busy: false,
|
|
||||||
labels: ['self-hosted'],
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Promise.resolve({ status: 200, data: { runners } });
|
|
||||||
});
|
|
||||||
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
|
||||||
|
|
||||||
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
|
||||||
|
|
||||||
// Should have stopped after timeout was detected (2 pages: first succeeds, second triggers timeout check)
|
|
||||||
expect(mockRequest.mock.calls.length).toBeLessThanOrEqual(3);
|
|
||||||
// Should have runners from pages fetched before timeout
|
|
||||||
expect(result.totalRunners).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Restore
|
|
||||||
Date.now = originalDateNow;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import { Octokit } from '@octokit/core';
|
|
||||||
import OrchestratorLogger from './orchestrator-logger';
|
|
||||||
|
|
||||||
interface GitHubRunner {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
status: 'online' | 'offline';
|
|
||||||
busy: boolean;
|
|
||||||
labels: Array<{ name: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RunnerCheckResult {
|
|
||||||
shouldFallback: boolean;
|
|
||||||
reason: string;
|
|
||||||
totalRunners: number;
|
|
||||||
matchingRunners: number;
|
|
||||||
idleRunners: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum number of pages to fetch when paginating through GitHub API results.
|
|
||||||
* 100 pages * 100 per page = 10,000 runners maximum.
|
|
||||||
*/
|
|
||||||
const MAX_PAGINATION_PAGES = 100;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Total timeout in milliseconds for the pagination loop.
|
|
||||||
* Prevents indefinite API calls if GitHub is slow or pagination is unexpectedly deep.
|
|
||||||
*/
|
|
||||||
const PAGINATION_TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks GitHub Actions runner availability to support automatic provider fallback.
|
|
||||||
*
|
|
||||||
* When a user configures `runnerCheckEnabled: true` with a `fallbackProviderStrategy`,
|
|
||||||
* this service queries the GitHub API for runner status before the build starts.
|
|
||||||
* If insufficient runners are available, the orchestrator routes to the fallback provider.
|
|
||||||
*/
|
|
||||||
export class RunnerAvailabilityService {
|
|
||||||
/**
|
|
||||||
* Check if enough runners are available to handle the build.
|
|
||||||
*
|
|
||||||
* @param owner - GitHub repository owner
|
|
||||||
* @param repo - GitHub repository name
|
|
||||||
* @param token - GitHub token with repo/actions scope
|
|
||||||
* @param requiredLabels - Labels runners must have (empty = any runner)
|
|
||||||
* @param minAvailable - Minimum idle runners required
|
|
||||||
* @returns RunnerCheckResult with decision and diagnostics
|
|
||||||
*/
|
|
||||||
static async checkAvailability(
|
|
||||||
owner: string,
|
|
||||||
repo: string,
|
|
||||||
token: string,
|
|
||||||
requiredLabels: string[],
|
|
||||||
minAvailable: number,
|
|
||||||
): Promise<RunnerCheckResult> {
|
|
||||||
if (!token) {
|
|
||||||
return {
|
|
||||||
shouldFallback: false,
|
|
||||||
reason: 'No GitHub token available — skipping runner check',
|
|
||||||
totalRunners: 0,
|
|
||||||
matchingRunners: 0,
|
|
||||||
idleRunners: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const octokit = new Octokit({ auth: token });
|
|
||||||
|
|
||||||
// Fetch all runners for the repository
|
|
||||||
const runners = await RunnerAvailabilityService.fetchRunners(octokit, owner, repo);
|
|
||||||
|
|
||||||
if (runners.length === 0) {
|
|
||||||
return {
|
|
||||||
shouldFallback: true,
|
|
||||||
reason: 'No runners registered for this repository',
|
|
||||||
totalRunners: 0,
|
|
||||||
matchingRunners: 0,
|
|
||||||
idleRunners: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by required labels
|
|
||||||
const matching = RunnerAvailabilityService.filterByLabels(runners, requiredLabels);
|
|
||||||
|
|
||||||
// Count idle (online + not busy)
|
|
||||||
const idle = matching.filter((r) => r.status === 'online' && !r.busy);
|
|
||||||
|
|
||||||
const result: RunnerCheckResult = {
|
|
||||||
shouldFallback: idle.length < minAvailable,
|
|
||||||
reason:
|
|
||||||
idle.length >= minAvailable
|
|
||||||
? `${idle.length} idle runner(s) available (need ${minAvailable})`
|
|
||||||
: `Only ${idle.length} idle runner(s) available, need ${minAvailable}`,
|
|
||||||
totalRunners: runners.length,
|
|
||||||
matchingRunners: matching.length,
|
|
||||||
idleRunners: idle.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error: any) {
|
|
||||||
// If the API call fails (permissions, rate limit, etc.), don't block the build
|
|
||||||
OrchestratorLogger.log(`Runner availability check failed: ${error.message}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
shouldFallback: false,
|
|
||||||
reason: `Runner check failed (${error.message}) — proceeding with primary provider`,
|
|
||||||
totalRunners: 0,
|
|
||||||
matchingRunners: 0,
|
|
||||||
idleRunners: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all runners for a repository, handling pagination.
|
|
||||||
*
|
|
||||||
* Includes defensive limits:
|
|
||||||
* - Maximum page count (MAX_PAGINATION_PAGES) to prevent infinite loops
|
|
||||||
* - Total timeout (PAGINATION_TIMEOUT_MS) to prevent indefinite API calls
|
|
||||||
* - Rate-limit detection (HTTP 403/429 with X-RateLimit-Remaining header)
|
|
||||||
*/
|
|
||||||
private static async fetchRunners(octokit: Octokit, owner: string, repo: string): Promise<GitHubRunner[]> {
|
|
||||||
const allRunners: GitHubRunner[] = [];
|
|
||||||
let page = 1;
|
|
||||||
const perPage = 100;
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
while (page <= MAX_PAGINATION_PAGES) {
|
|
||||||
// Check total timeout
|
|
||||||
if (Date.now() - startTime > PAGINATION_TIMEOUT_MS) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`[RunnerAvailability] Pagination timeout reached after ${page - 1} pages and ${Date.now() - startTime}ms. ` +
|
|
||||||
`Using ${allRunners.length} runners found so far.`,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: any;
|
|
||||||
try {
|
|
||||||
response = await octokit.request('GET /repos/{owner}/{repo}/actions/runners', {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
per_page: perPage,
|
|
||||||
page,
|
|
||||||
});
|
|
||||||
} catch (requestError: any) {
|
|
||||||
// Octokit throws for non-2xx responses. Check if this is a rate limit error.
|
|
||||||
const status = requestError.status ?? requestError.response?.status;
|
|
||||||
if (status === 403 || status === 429) {
|
|
||||||
const resetTime =
|
|
||||||
requestError.response?.headers?.['x-ratelimit-reset'] ?? requestError.headers?.['x-ratelimit-reset'];
|
|
||||||
const resetMessage = resetTime
|
|
||||||
? ` Resets at ${new Date(Number.parseInt(String(resetTime), 10) * 1000).toISOString()}`
|
|
||||||
: '';
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`[RunnerAvailability] GitHub API rate limit reached (HTTP ${status}).${resetMessage} ` +
|
|
||||||
`Using ${allRunners.length} runners found so far.`,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Re-throw non-rate-limit errors to be handled by the outer catch
|
|
||||||
throw requestError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runners = (response.data.runners || []) as GitHubRunner[];
|
|
||||||
allRunners.push(...runners);
|
|
||||||
|
|
||||||
if (runners.length < perPage) break;
|
|
||||||
page++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page > MAX_PAGINATION_PAGES) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`[RunnerAvailability] Maximum pagination limit reached (${MAX_PAGINATION_PAGES} pages). ` +
|
|
||||||
`Using ${allRunners.length} runners found so far.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allRunners.length === 0) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
'[RunnerAvailability] No runners found. Possible causes: ' +
|
|
||||||
'wrong token permissions (needs repo or actions scope), ' +
|
|
||||||
'no self-hosted runners registered, ' +
|
|
||||||
'or runners are registered at the organization level instead of the repository.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return allRunners;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter runners by required labels. A runner matches if it has ALL required labels.
|
|
||||||
* If requiredLabels is empty, all runners match.
|
|
||||||
*/
|
|
||||||
private static filterByLabels(runners: GitHubRunner[], requiredLabels: string[]): GitHubRunner[] {
|
|
||||||
if (requiredLabels.length === 0) return runners;
|
|
||||||
|
|
||||||
return runners.filter((runner) => {
|
|
||||||
const runnerLabelNames = runner.labels.map((l) => l.name.toLowerCase());
|
|
||||||
|
|
||||||
return requiredLabels.every((required) => runnerLabelNames.includes(required.toLowerCase()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { OutputManifest, OutputEntry } from './output-manifest';
|
||||||
|
export { OutputTypeRegistry, OutputTypeDefinition } from './output-type-registry';
|
||||||
|
export { OutputService } from './output-service';
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Structured build output manifest.
|
||||||
|
* Describes all artifacts produced by a build with type, path, size, hash, and metadata.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface OutputEntry {
|
||||||
|
/** Output type identifier (e.g., 'build', 'test-results', 'images') */
|
||||||
|
type: string;
|
||||||
|
|
||||||
|
/** Relative path to the output */
|
||||||
|
path: string;
|
||||||
|
|
||||||
|
/** Output format (e.g., 'nunit3', 'junit', 'json') */
|
||||||
|
format?: string;
|
||||||
|
|
||||||
|
/** File size in bytes */
|
||||||
|
size?: number;
|
||||||
|
|
||||||
|
/** Content hash (e.g., 'sha256:abc...') */
|
||||||
|
hash?: string;
|
||||||
|
|
||||||
|
/** Individual files within the output path */
|
||||||
|
files?: string[];
|
||||||
|
|
||||||
|
/** Type-specific summary (e.g., test counts, build size) */
|
||||||
|
summary?: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** Arbitrary metadata */
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutputManifest {
|
||||||
|
/** Unique build identifier */
|
||||||
|
buildGuid: string;
|
||||||
|
|
||||||
|
/** ISO 8601 timestamp */
|
||||||
|
timestamp: string;
|
||||||
|
|
||||||
|
/** All outputs produced by this build */
|
||||||
|
outputs: OutputEntry[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||||
|
import { OutputManifest, OutputEntry } from './output-manifest';
|
||||||
|
import { OutputTypeRegistry } from './output-type-registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for collecting, manifesting, and managing build outputs.
|
||||||
|
*
|
||||||
|
* After a build completes, this service scans declared output paths,
|
||||||
|
* generates a structured manifest, and prepares outputs for post-processing.
|
||||||
|
*/
|
||||||
|
export class OutputService {
|
||||||
|
/**
|
||||||
|
* Collect outputs from the workspace and generate a manifest.
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the Unity project root
|
||||||
|
* @param buildGuid - Unique build identifier
|
||||||
|
* @param outputTypesInput - Comma-separated output type names
|
||||||
|
* @param manifestPath - Where to write the manifest JSON (optional)
|
||||||
|
* @returns The generated output manifest
|
||||||
|
*/
|
||||||
|
static async collectOutputs(
|
||||||
|
projectPath: string,
|
||||||
|
buildGuid: string,
|
||||||
|
outputTypesInput: string,
|
||||||
|
manifestPath?: string,
|
||||||
|
): Promise<OutputManifest> {
|
||||||
|
const types = OutputTypeRegistry.parseOutputTypes(outputTypesInput);
|
||||||
|
const manifest: OutputManifest = {
|
||||||
|
buildGuid,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
outputs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (types.length === 0) {
|
||||||
|
OrchestratorLogger.log('[Output] No output types declared, skipping collection');
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
OrchestratorLogger.log(
|
||||||
|
`[Output] Collecting ${types.length} output type(s): ${types.map((t) => t.name).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const typeDef of types) {
|
||||||
|
const outputPath = path.join(
|
||||||
|
projectPath,
|
||||||
|
typeDef.defaultPath.replace('{platform}', process.env.BUILD_TARGET || 'Unknown'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(outputPath)) {
|
||||||
|
OrchestratorLogger.log(`[Output] No output found for '${typeDef.name}' at ${outputPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: OutputEntry = {
|
||||||
|
type: typeDef.name,
|
||||||
|
path: typeDef.defaultPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect file listing for directory outputs
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(outputPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
entry.files = fs.readdirSync(outputPath);
|
||||||
|
entry.size = OutputService.getDirectorySize(outputPath);
|
||||||
|
} else {
|
||||||
|
entry.size = stat.size;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
OrchestratorLogger.logWarning(`[Output] Failed to stat output '${typeDef.name}' at ${outputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.outputs.push(entry);
|
||||||
|
OrchestratorLogger.log(
|
||||||
|
`[Output] Collected '${typeDef.name}': ${entry.files?.length || 1} file(s), ${entry.size || 0} bytes`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write manifest to disk
|
||||||
|
if (manifestPath) {
|
||||||
|
try {
|
||||||
|
const manifestDir = path.dirname(manifestPath);
|
||||||
|
fs.mkdirSync(manifestDir, { recursive: true });
|
||||||
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
||||||
|
OrchestratorLogger.log(`[Output] Manifest written to ${manifestPath}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
OrchestratorLogger.logWarning(`[Output] Failed to write manifest: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total size of a directory recursively.
|
||||||
|
*/
|
||||||
|
private static getDirectorySize(dirPath: string): number {
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dirPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
totalSize += OutputService.getDirectorySize(fullPath);
|
||||||
|
} else {
|
||||||
|
totalSize += fs.statSync(fullPath).size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors in size calculation
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of known output types with default paths and processing hints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface OutputTypeDefinition {
|
||||||
|
/** Type identifier */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** Default output path (relative to project root) */
|
||||||
|
defaultPath: string;
|
||||||
|
|
||||||
|
/** Human-readable description */
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
/** Whether this type is built-in or user-registered */
|
||||||
|
builtIn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OutputTypeRegistry {
|
||||||
|
private static readonly builtInTypes: Record<string, OutputTypeDefinition> = {
|
||||||
|
build: {
|
||||||
|
name: 'build',
|
||||||
|
defaultPath: './Builds/{platform}/',
|
||||||
|
description: 'Standard game build artifact',
|
||||||
|
builtIn: true,
|
||||||
|
},
|
||||||
|
'test-results': {
|
||||||
|
name: 'test-results',
|
||||||
|
defaultPath: './TestResults/',
|
||||||
|
description: 'NUnit/JUnit XML test results',
|
||||||
|
builtIn: true,
|
||||||
|
},
|
||||||
|
'server-build': {
|
||||||
|
name: 'server-build',
|
||||||
|
defaultPath: './Builds/{platform}-server/',
|
||||||
|
description: 'Dedicated server build artifact',
|
||||||
|
builtIn: true,
|
||||||
|
},
|
||||||
|
'data-export': {
|
||||||
|
name: 'data-export',
|
||||||
|
defaultPath: './Exports/',
|
||||||
|
description: 'Exported data files (CSV, JSON, binary)',
|
||||||
|
builtIn: true,
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
name: 'images',
|
||||||
|
defaultPath: './Captures/',
|
||||||
|
description: 'Screenshots, render captures, atlas previews',
|
||||||
|
builtIn: true,
|
||||||
|
},
|
||||||
|
logs: {
|
||||||
|
name: 'logs',
|
||||||
|
defaultPath: './Logs/',
|
||||||
|
description: 'Structured build and test logs',
|
||||||
|
builtIn: true,
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
name: 'metrics',
|
||||||
|
defaultPath: './Metrics/',
|
||||||
|
description: 'Build performance metrics and asset statistics',
|
||||||
|
builtIn: true,
|
||||||
|
},
|
||||||
|
coverage: {
|
||||||
|
name: 'coverage',
|
||||||
|
defaultPath: './Coverage/',
|
||||||
|
description: 'Code coverage reports',
|
||||||
|
builtIn: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
private static customTypes: Record<string, OutputTypeDefinition> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a type definition by name. Checks custom types first, then built-in.
|
||||||
|
*/
|
||||||
|
static getType(name: string): OutputTypeDefinition | undefined {
|
||||||
|
return OutputTypeRegistry.customTypes[name] || OutputTypeRegistry.builtInTypes[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered types (built-in + custom).
|
||||||
|
*/
|
||||||
|
static getAllTypes(): OutputTypeDefinition[] {
|
||||||
|
return [...Object.values(OutputTypeRegistry.builtInTypes), ...Object.values(OutputTypeRegistry.customTypes)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom output type.
|
||||||
|
*/
|
||||||
|
static registerType(definition: OutputTypeDefinition): void {
|
||||||
|
if (OutputTypeRegistry.builtInTypes[definition.name]) {
|
||||||
|
OrchestratorLogger.logWarning(`[OutputTypes] Cannot override built-in type '${definition.name}'`);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OutputTypeRegistry.customTypes[definition.name] = { ...definition, builtIn: false };
|
||||||
|
OrchestratorLogger.log(`[OutputTypes] Registered custom type '${definition.name}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a comma-separated output types string into type definitions.
|
||||||
|
* Unknown types are logged as warnings and skipped.
|
||||||
|
*/
|
||||||
|
static parseOutputTypes(outputTypesInput: string): OutputTypeDefinition[] {
|
||||||
|
if (!outputTypesInput) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = outputTypesInput
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const types: OutputTypeDefinition[] = [];
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
|
const typeDef = OutputTypeRegistry.getType(name);
|
||||||
|
if (typeDef) {
|
||||||
|
types.push(typeDef);
|
||||||
|
} else {
|
||||||
|
OrchestratorLogger.logWarning(`[OutputTypes] Unknown output type '${name}', skipping`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset custom types (for testing).
|
||||||
|
*/
|
||||||
|
static resetCustomTypes(): void {
|
||||||
|
OutputTypeRegistry.customTypes = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user