Compare commits

..

6 Commits

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:01:07 +00:00
frostebite
ae03bd2f13 ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:33:16 +00:00
frostebite
1e2bb889bf style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:13:02 +00:00
frostebite
7615bbd9dd fix(artifacts): validate rclone availability before storage upload
Check for rclone binary before attempting storage-based uploads.
Validate storage destination URI format (remoteName:path).
Provide clear error message with install link when rclone is missing.
Fail gracefully instead of cryptic ENOENT crash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:00:13 +00:00
frostebite
aa2e05d468 feat(artifacts): complete generic artifact system with upload handlers, tests, and action integration (#798)
- Add ArtifactUploadHandler with support for github-artifacts, storage (rclone),
  and local copy upload targets, including large file chunking for GitHub Artifacts
- Add 44 unit tests covering OutputTypeRegistry, OutputService, and
  ArtifactUploadHandler (config parsing, upload coordination, file collection)
- Add 6 new action.yml inputs for artifact configuration
- Add artifactManifestPath action output
- Wire artifact collection and upload into index.ts post-build flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:51:43 +00:00
frostebite
b3e1639029 feat(orchestrator): generic artifact system — output types, manifests, and collection service
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:38:55 +00:00
16 changed files with 2124 additions and 2086 deletions

View File

@@ -182,8 +182,8 @@ inputs:
required: false
default: ''
description:
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with
the keys image, secrets (name, value object array), command line string)'
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with the
keys image, secrets (name, value object array), command line string)'
awsStackName:
default: 'game-ci'
required: false
@@ -269,6 +269,28 @@ inputs:
default: 'false'
required: false
description: 'Skip the activation/deactivation of Unity. This assumes Unity is already activated.'
artifactOutputTypes:
description: 'Comma-separated list of output types to collect (build, logs, test-results, coverage, images, metrics, data-export, server-build, custom)'
required: false
default: 'build,logs,test-results'
artifactUploadTarget:
description: 'Where to upload artifacts: github-artifacts, storage, local, none'
required: false
default: 'github-artifacts'
artifactUploadPath:
description: 'Destination path for artifact upload (storage URI or local path)'
required: false
artifactCompression:
description: 'Compression for artifacts: none, gzip, lz4'
required: false
default: 'gzip'
artifactRetentionDays:
description: 'Retention period for uploaded artifacts in days'
required: false
default: '30'
artifactCustomTypes:
description: 'JSON string defining custom output types [{name, defaultPath, description}]'
required: false
cloneDepth:
default: '50'
required: false
@@ -279,120 +301,6 @@ inputs:
description:
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
fixes.'
gcpProject:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Google Cloud project ID for Cloud Run Jobs provider. Falls back to
GOOGLE_CLOUD_PROJECT env var.'
gcpRegion:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Google Cloud region for Cloud Run Jobs (e.g. us-central1). Defaults to the region
input if empty.'
gcpStorageType:
required: false
default: 'gcs-fuse'
description:
'[Orchestrator] [Experimental] Storage type for Cloud Run Jobs. Options: gcs-fuse (mount GCS bucket as filesystem,
unlimited size, best for large sequential I/O), gcs-copy (copy artifacts in/out via gsutil, simpler, no FUSE
overhead), nfs (Filestore NFS mount, true POSIX, good random I/O, up to 100 TiB), in-memory (tmpfs, fastest but
volatile, up to 32 GiB).'
gcpBucket:
required: false
default: ''
description:
'[Orchestrator] [Experimental] GCS bucket name for build artifact storage. Used by gcs-fuse and gcs-copy storage
types.'
gcpFilestoreIp:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Filestore instance IP address for NFS storage type. Required when gcpStorageType is
nfs.'
gcpFilestoreShare:
required: false
default: '/share1'
description:
'[Orchestrator] [Experimental] Filestore share name for NFS storage type. Defaults to /share1 (the Filestore
default).'
gcpMachineType:
required: false
default: 'e2-standard-4'
description: '[Orchestrator] [Experimental] Machine type for Cloud Run Jobs (e.g. e2-standard-4, e2-highmem-8).'
gcpDiskSizeGb:
required: false
default: '100'
description:
'[Orchestrator] [Experimental] Disk size in GB for Cloud Run Jobs in-memory volumes. Only applies to in-memory
storage type (max 32).'
gcpServiceAccount:
required: false
default: ''
description: '[Orchestrator] [Experimental] Google Cloud service account email for Cloud Run Jobs execution.'
gcpVpcConnector:
required: false
default: ''
description: '[Orchestrator] [Experimental] VPC connector name for Cloud Run Jobs private networking.'
azureResourceGroup:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure resource group for Container Instances provider. Falls back to
AZURE_RESOURCE_GROUP env var.'
azureLocation:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure region for Container Instances (e.g. eastus, westeurope). Defaults to the
region input if empty.'
azureStorageType:
required: false
default: 'azure-files'
description:
'[Orchestrator] [Experimental] Storage type for Azure Container Instances. Options: azure-files (SMB file share
mount, up to 100 TiB, premium throughput), blob-copy (copy artifacts in/out via az storage blob, no mount
overhead), azure-files-nfs (NFS 4.1 file share mount, true POSIX, no SMB lock overhead), in-memory (emptyDir
tmpfs, fastest but volatile, size limited by container memory).'
azureStorageAccount:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure Storage Account name. Used by azure-files, azure-files-nfs, and blob-copy
storage types.'
azureFileShareName:
required: false
default: 'unity-builds'
description:
'[Orchestrator] [Experimental] Azure File Share name within the storage account. Used by azure-files and
azure-files-nfs storage types. Supports up to 100 TiB per share.'
azureBlobContainer:
required: false
default: 'unity-builds'
description: '[Orchestrator] [Experimental] Azure Blob container name for blob-copy storage type.'
azureSubscriptionId:
required: false
default: ''
description: '[Orchestrator] [Experimental] Azure subscription ID. Falls back to AZURE_SUBSCRIPTION_ID env var.'
azureCpu:
required: false
default: '4'
description: '[Orchestrator] [Experimental] CPU cores for Azure Container Instances (1-16).'
azureMemoryGb:
required: false
default: '16'
description: '[Orchestrator] [Experimental] Memory in GB for Azure Container Instances (1-16).'
azureDiskSizeGb:
required: false
default: '100'
description:
'[Orchestrator] [Experimental] File share quota in GB for Azure Container Instances. Premium shares support up to
102400 GB (100 TiB).'
azureSubnetId:
required: false
default: ''
description: '[Orchestrator] [Experimental] Azure subnet resource ID for VNet-integrated Container Instances.'
outputs:
volume:
@@ -406,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
during activation, the code is from the activation step. If activation is successful, the code is from the project
build step.'
artifactManifestPath:
description: 'Path to the generated artifact manifest JSON file'
branding:
icon: 'box'
color: 'gray-dark'

1472
dist/index.js generated vendored

File diff suppressed because it is too large Load Diff

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,12 @@
import * as core from '@actions/core';
import path from 'node:path';
import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output } from './model';
import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup';
import { OutputService } from './model/orchestrator/services/output/output-service';
import { OutputTypeRegistry } from './model/orchestrator/services/output/output-type-registry';
import { ArtifactUploadHandler } from './model/orchestrator/services/output/artifact-upload-handler';
async function runMain() {
try {
@@ -42,6 +46,64 @@ async function runMain() {
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
await Output.setEngineExitCode(exitCode);
// Artifact collection and upload (runs on both success and failure)
try {
// Register custom output types if provided
if (buildParameters.artifactCustomTypes) {
try {
const customTypes = JSON.parse(buildParameters.artifactCustomTypes);
if (Array.isArray(customTypes)) {
for (const ct of customTypes) {
OutputTypeRegistry.registerType({
name: ct.name,
defaultPath: ct.defaultPath || ct.pattern || `./${ct.name}/`,
description: ct.description || `Custom output type: ${ct.name}`,
builtIn: false,
});
}
}
} catch (parseError) {
core.warning(`Failed to parse artifactCustomTypes: ${(parseError as Error).message}`);
}
}
// Collect outputs and generate manifest
const manifestPath = path.join(buildParameters.projectPath, 'output-manifest.json');
const manifest = await OutputService.collectOutputs(
buildParameters.projectPath,
buildParameters.buildGuid,
buildParameters.artifactOutputTypes,
manifestPath,
);
core.setOutput('artifactManifestPath', manifestPath);
// Upload artifacts
const uploadConfig = ArtifactUploadHandler.parseConfig(
buildParameters.artifactUploadTarget,
buildParameters.artifactUploadPath || undefined,
buildParameters.artifactCompression,
buildParameters.artifactRetentionDays,
);
const uploadResult = await ArtifactUploadHandler.uploadArtifacts(
manifest,
uploadConfig,
buildParameters.projectPath,
);
if (!uploadResult.success) {
core.warning(
`Artifact upload completed with errors: ${uploadResult.entries
.filter((e) => !e.success)
.map((e) => `${e.type}: ${e.error}`)
.join('; ')}`,
);
}
} catch (artifactError) {
core.warning(`Artifact collection/upload failed: ${(artifactError as Error).message}`);
}
if (exitCode !== 0) {
core.setFailed(`Build failed with exit code ${exitCode}`);
}

View File

@@ -106,31 +106,12 @@ class BuildParameters {
public cacheUnityInstallationOnMac!: boolean;
public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: string;
// GCP Cloud Run (Experimental)
public gcpProject!: string;
public gcpRegion!: string;
public gcpStorageType!: string;
public gcpBucket!: string;
public gcpFilestoreIp!: string;
public gcpFilestoreShare!: string;
public gcpMachineType!: string;
public gcpDiskSizeGb!: string;
public gcpServiceAccount!: string;
public gcpVpcConnector!: string;
// Azure Container Instances (Experimental)
public azureResourceGroup!: string;
public azureLocation!: string;
public azureStorageType!: string;
public azureStorageAccount!: string;
public azureBlobContainer!: string;
public azureFileShareName!: string;
public azureSubscriptionId!: string;
public azureCpu!: string;
public azureMemoryGb!: string;
public azureDiskSizeGb!: string;
public azureSubnetId!: string;
public artifactOutputTypes!: string;
public artifactUploadTarget!: string;
public artifactUploadPath!: string;
public artifactCompression!: string;
public artifactRetentionDays!: string;
public artifactCustomTypes!: string;
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
@@ -253,27 +234,6 @@ class BuildParameters {
inputPullCommand: OrchestratorOptions.inputPullCommand,
pullInputList: OrchestratorOptions.pullInputList,
kubeStorageClass: OrchestratorOptions.kubeStorageClass,
gcpProject: Input.gcpProject,
gcpRegion: Input.gcpRegion,
gcpStorageType: Input.gcpStorageType,
gcpBucket: Input.gcpBucket,
gcpFilestoreIp: Input.gcpFilestoreIp,
gcpFilestoreShare: Input.gcpFilestoreShare,
gcpMachineType: Input.gcpMachineType,
gcpDiskSizeGb: Input.gcpDiskSizeGb,
gcpServiceAccount: Input.gcpServiceAccount,
gcpVpcConnector: Input.gcpVpcConnector,
azureResourceGroup: Input.azureResourceGroup,
azureLocation: Input.azureLocation,
azureStorageType: Input.azureStorageType,
azureStorageAccount: Input.azureStorageAccount,
azureBlobContainer: Input.azureBlobContainer,
azureFileShareName: Input.azureFileShareName,
azureSubscriptionId: Input.azureSubscriptionId,
azureCpu: Input.azureCpu,
azureMemoryGb: Input.azureMemoryGb,
azureDiskSizeGb: Input.azureDiskSizeGb,
azureSubnetId: Input.azureSubnetId,
cacheKey: OrchestratorOptions.cacheKey,
maxRetainedWorkspaces: Number.parseInt(OrchestratorOptions.maxRetainedWorkspaces),
useLargePackages: OrchestratorOptions.useLargePackages,
@@ -288,6 +248,12 @@ class BuildParameters {
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath,
artifactOutputTypes: Input.artifactOutputTypes,
artifactUploadTarget: Input.artifactUploadTarget,
artifactUploadPath: Input.artifactUploadPath,
artifactCompression: Input.artifactCompression,
artifactRetentionDays: Input.artifactRetentionDays,
artifactCustomTypes: Input.artifactCustomTypes,
};
}

View File

@@ -278,96 +278,34 @@ class Input {
return Input.getInput('containerRegistryImageVersion') ?? '3';
}
static get artifactOutputTypes(): string {
return Input.getInput('artifactOutputTypes') ?? 'build,logs,test-results';
}
static get artifactUploadTarget(): string {
return Input.getInput('artifactUploadTarget') ?? 'github-artifacts';
}
static get artifactUploadPath(): string {
return Input.getInput('artifactUploadPath') ?? '';
}
static get artifactCompression(): string {
return Input.getInput('artifactCompression') ?? 'gzip';
}
static get artifactRetentionDays(): string {
return Input.getInput('artifactRetentionDays') ?? '30';
}
static get artifactCustomTypes(): string {
return Input.getInput('artifactCustomTypes') ?? '';
}
static get skipActivation(): string {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
// GCP Cloud Run (Experimental)
static get gcpProject(): string {
return Input.getInput('gcpProject') ?? '';
}
static get gcpRegion(): string {
return Input.getInput('gcpRegion') ?? '';
}
static get gcpStorageType(): string {
return Input.getInput('gcpStorageType') ?? 'gcs-fuse';
}
static get gcpBucket(): string {
return Input.getInput('gcpBucket') ?? '';
}
static get gcpFilestoreIp(): string {
return Input.getInput('gcpFilestoreIp') ?? '';
}
static get gcpFilestoreShare(): string {
return Input.getInput('gcpFilestoreShare') ?? '/share1';
}
static get gcpMachineType(): string {
return Input.getInput('gcpMachineType') ?? 'e2-standard-4';
}
static get gcpDiskSizeGb(): string {
return Input.getInput('gcpDiskSizeGb') ?? '100';
}
static get gcpServiceAccount(): string {
return Input.getInput('gcpServiceAccount') ?? '';
}
static get gcpVpcConnector(): string {
return Input.getInput('gcpVpcConnector') ?? '';
}
// Azure Container Instances (Experimental)
static get azureResourceGroup(): string {
return Input.getInput('azureResourceGroup') ?? '';
}
static get azureLocation(): string {
return Input.getInput('azureLocation') ?? '';
}
static get azureStorageType(): string {
return Input.getInput('azureStorageType') ?? 'azure-files';
}
static get azureStorageAccount(): string {
return Input.getInput('azureStorageAccount') ?? '';
}
static get azureBlobContainer(): string {
return Input.getInput('azureBlobContainer') ?? 'unity-builds';
}
static get azureFileShareName(): string {
return Input.getInput('azureFileShareName') ?? 'unity-builds';
}
static get azureSubscriptionId(): string {
return Input.getInput('azureSubscriptionId') ?? '';
}
static get azureCpu(): string {
return Input.getInput('azureCpu') ?? '4';
}
static get azureMemoryGb(): string {
return Input.getInput('azureMemoryGb') ?? '16';
}
static get azureDiskSizeGb(): string {
return Input.getInput('azureDiskSizeGb') ?? '100';
}
static get azureSubnetId(): string {
return Input.getInput('azureSubnetId') ?? '';
}
public static ToEnvVarFormat(input: string) {
if (input.toUpperCase() === input) {
return input;

View File

@@ -13,8 +13,6 @@ import OrchestratorEnvironmentVariable from './options/orchestrator-environment-
import TestOrchestrator from './providers/test';
import LocalOrchestrator from './providers/local';
import LocalDockerOrchestrator from './providers/docker';
import GcpCloudRunProvider from './providers/gcp-cloud-run';
import AzureAciProvider from './providers/azure-aci';
import loadProvider from './providers/provider-loader';
import GitHub from '../github';
import SharedWorkspaceLocking from './services/core/shared-workspace-locking';
@@ -160,14 +158,6 @@ class Orchestrator {
case 'local':
Orchestrator.Provider = new LocalOrchestrator();
break;
case 'gcp-cloud-run':
OrchestratorLogger.log('⚠ EXPERIMENTAL: GCP Cloud Run Jobs provider');
Orchestrator.Provider = new GcpCloudRunProvider(Orchestrator.buildParameters);
break;
case 'azure-aci':
OrchestratorLogger.log('⚠ EXPERIMENTAL: Azure Container Instances provider');
Orchestrator.Provider = new AzureAciProvider(Orchestrator.buildParameters);
break;
default:
// Try to load provider using the dynamic loader for unknown providers
try {

View File

@@ -1,536 +0,0 @@
/**
* Azure Container Instances (ACI) Provider (Experimental)
*
* Executes Unity builds as Azure Container Instances with configurable storage backends.
*
* Storage types:
* - azure-files: SMB file share mount via Azure Files. Up to 100 TiB per share,
* premium throughput. Default.
* Requires: azureStorageAccount, azureFileShareName
* - blob-copy: Copy artifacts in/out of Azure Blob Storage before/after the build.
* No mount overhead, simpler.
* Requires: azureStorageAccount, azureBlobContainer
* - azure-files-nfs: NFS 4.1 file share mount. True POSIX semantics, no SMB lock overhead,
* better for Unity Library caching (many small random reads).
* Requires: azureStorageAccount, azureFileShareName, Premium FileStorage,
* VNet integration (azureSubnetId)
* - in-memory: emptyDir volume (tmpfs). Fastest I/O but volatile, size limited by
* container memory allocation.
*
* Prerequisites:
* - Azure CLI authenticated (az login or service principal)
* - A resource group for build resources
* - Contributor role on the resource group
*
* @experimental This provider is experimental. APIs and behavior may change.
*/
import { ProviderInterface } from '../provider-interface';
import BuildParameters from '../../../build-parameters';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import { Input } from '../../..';
import ResourceTracking from '../../services/core/resource-tracking';
type AzureStorageType = 'azure-files' | 'blob-copy' | 'azure-files-nfs' | 'in-memory';
class AzureAciProvider implements ProviderInterface {
private readonly resourceGroup: string;
private readonly location: string;
private readonly storageType: AzureStorageType;
private readonly storageAccount: string;
private readonly blobContainer: string;
private readonly fileShareName: string;
private readonly subscriptionId: string;
private readonly cpu: number;
private readonly memoryGb: number;
private readonly diskSizeGb: number;
private readonly subnetId: string;
private buildParameters: BuildParameters;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.resourceGroup = buildParameters.azureResourceGroup || process.env.AZURE_RESOURCE_GROUP || '';
this.location = buildParameters.azureLocation || Input.region || 'eastus';
this.storageType = (buildParameters.azureStorageType || 'azure-files') as AzureStorageType;
this.storageAccount = buildParameters.azureStorageAccount || process.env.AZURE_STORAGE_ACCOUNT || '';
this.blobContainer = buildParameters.azureBlobContainer || 'unity-builds';
this.fileShareName = buildParameters.azureFileShareName || 'unity-builds';
this.subscriptionId = buildParameters.azureSubscriptionId || process.env.AZURE_SUBSCRIPTION_ID || '';
this.cpu = Number.parseInt(buildParameters.azureCpu || '4', 10);
this.memoryGb = Number.parseInt(buildParameters.azureMemoryGb || '16', 10);
this.diskSizeGb = Number.parseInt(buildParameters.azureDiskSizeGb || '100', 10);
this.subnetId = buildParameters.azureSubnetId || '';
OrchestratorLogger.log('[Azure ACI] Provider initialized (EXPERIMENTAL)');
OrchestratorLogger.log(`[Azure ACI] Resource Group: ${this.resourceGroup || '(not set)'}`);
OrchestratorLogger.log(`[Azure ACI] Location: ${this.location}`);
OrchestratorLogger.log(`[Azure ACI] Storage: ${this.storageType}`);
OrchestratorLogger.log(`[Azure ACI] Resources: ${this.cpu} CPU, ${this.memoryGb}GB RAM`);
this.validateStorageConfig();
}
private validateStorageConfig(): void {
switch (this.storageType) {
case 'azure-files':
if (!this.storageAccount) {
OrchestratorLogger.logWarning(
'[Azure ACI] Storage type "azure-files" requires azureStorageAccount to be set.',
);
} else {
OrchestratorLogger.log(`[Azure ACI] File Share: ${this.storageAccount}/${this.fileShareName} (SMB)`);
}
break;
case 'azure-files-nfs':
if (!this.storageAccount) {
OrchestratorLogger.logWarning(
'[Azure ACI] Storage type "azure-files-nfs" requires azureStorageAccount (Premium FileStorage).',
);
}
if (!this.subnetId) {
OrchestratorLogger.logWarning('[Azure ACI] NFS file shares require VNet integration. Set azureSubnetId.');
} else {
OrchestratorLogger.log(`[Azure ACI] File Share: ${this.storageAccount}/${this.fileShareName} (NFS 4.1)`);
}
break;
case 'blob-copy':
if (!this.storageAccount) {
OrchestratorLogger.logWarning('[Azure ACI] Storage type "blob-copy" requires azureStorageAccount to be set.');
} else {
OrchestratorLogger.log(`[Azure ACI] Blob container: ${this.storageAccount}/${this.blobContainer}`);
}
break;
case 'in-memory':
OrchestratorLogger.log(
`[Azure ACI] In-memory volume (emptyDir): limited by ${this.memoryGb}GB container memory`,
);
break;
default:
OrchestratorLogger.logWarning(
`[Azure ACI] Unknown storage type '${this.storageType}'. Valid: azure-files, blob-copy, azure-files-nfs, in-memory`,
);
}
if (!this.resourceGroup) {
OrchestratorLogger.logWarning(
'[Azure ACI] No resource group specified. Set azureResourceGroup input or AZURE_RESOURCE_GROUP env var.',
);
}
}
async setupWorkflow(
buildGuid: string,
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
OrchestratorLogger.log(`[Azure ACI] Setting up workflow for build ${buildGuid}`);
ResourceTracking.logAllocationSummary('azure-aci setup');
// Verify Azure CLI is available
try {
await OrchestratorSystem.Run('az version --output json', false, true);
OrchestratorLogger.log('[Azure ACI] Azure CLI detected');
} catch {
throw new Error(
'[Azure ACI] Azure CLI not found. Install Azure CLI: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli',
);
}
if (this.subscriptionId) {
await OrchestratorSystem.Run(`az account set --subscription="${this.subscriptionId}"`);
}
// Ensure resource group exists
if (this.resourceGroup) {
try {
await OrchestratorSystem.Run(`az group show --name "${this.resourceGroup}" --output json`, false, true);
OrchestratorLogger.log(`[Azure ACI] Resource group ${this.resourceGroup} exists`);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating resource group ${this.resourceGroup}`);
await OrchestratorSystem.Run(`az group create --name "${this.resourceGroup}" --location "${this.location}"`);
}
}
// Storage-specific setup
switch (this.storageType) {
case 'azure-files':
await this.setupStorageAccount('Standard_LRS', 'StorageV2');
await this.setupFileShare();
break;
case 'azure-files-nfs':
await this.setupStorageAccount('Premium_LRS', 'FileStorage');
await this.setupNfsFileShare();
break;
case 'blob-copy':
await this.setupStorageAccount('Standard_LRS', 'StorageV2');
await this.setupBlobContainer();
break;
case 'in-memory':
// No storage setup needed
break;
}
}
private async setupStorageAccount(sku: string, kind: string): Promise<void> {
if (!this.storageAccount || !this.resourceGroup) return;
try {
await OrchestratorSystem.Run(
`az storage account show --name "${this.storageAccount}" --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
OrchestratorLogger.log(`[Azure ACI] Storage account ${this.storageAccount} exists`);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating storage account ${this.storageAccount} (${sku}, ${kind})`);
await OrchestratorSystem.Run(
`az storage account create --name "${this.storageAccount}" --resource-group "${this.resourceGroup}" --location "${this.location}" --sku ${sku} --kind ${kind}`,
);
}
}
private async setupFileShare(): Promise<void> {
if (!this.storageAccount || !this.resourceGroup) return;
try {
await OrchestratorSystem.Run(
`az storage share-rm show --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating file share ${this.fileShareName} (${this.diskSizeGb}GB)`);
await OrchestratorSystem.Run(
`az storage share-rm create --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --quota ${this.diskSizeGb}`,
);
}
}
private async setupNfsFileShare(): Promise<void> {
if (!this.storageAccount || !this.resourceGroup) return;
try {
await OrchestratorSystem.Run(
`az storage share-rm show --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating NFS file share ${this.fileShareName} (${this.diskSizeGb}GB)`);
await OrchestratorSystem.Run(
`az storage share-rm create --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --quota ${this.diskSizeGb} --enabled-protocols NFS`,
);
}
}
private async setupBlobContainer(): Promise<void> {
if (!this.storageAccount || !this.resourceGroup) return;
try {
await OrchestratorSystem.Run(
`az storage container show --name "${this.blobContainer}" --account-name "${this.storageAccount}" --output json`,
false,
true,
);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating blob container ${this.blobContainer}`);
await OrchestratorSystem.Run(
`az storage container create --name "${this.blobContainer}" --account-name "${this.storageAccount}"`,
);
}
}
private async getStorageKey(): Promise<string> {
if (!this.storageAccount || !this.resourceGroup) return '';
try {
const keyJson = await OrchestratorSystem.Run(
`az storage account keys list --account-name "${this.storageAccount}" --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
const keys = JSON.parse(keyJson);
return keys[0]?.value || '';
} catch (error: any) {
OrchestratorLogger.logWarning(`[Azure ACI] Could not get storage key: ${error.message}`);
return '';
}
}
private async buildVolumeFlags(mountdir: string): Promise<string> {
switch (this.storageType) {
case 'azure-files': {
const storageKey = await this.getStorageKey();
if (!storageKey) return '';
return [
`--azure-file-volume-account-name "${this.storageAccount}"`,
`--azure-file-volume-account-key "${storageKey}"`,
`--azure-file-volume-share-name "${this.fileShareName}"`,
`--azure-file-volume-mount-path "${mountdir}"`,
].join(' ');
}
case 'azure-files-nfs': {
// ACI NFS mount uses a YAML deployment template; for CLI we use the same
// azure-file-volume flags but the share must be NFS-enabled and
// the container must be in a VNet
const storageKey = await this.getStorageKey();
if (!storageKey) return '';
return [
`--azure-file-volume-account-name "${this.storageAccount}"`,
`--azure-file-volume-account-key "${storageKey}"`,
`--azure-file-volume-share-name "${this.fileShareName}"`,
`--azure-file-volume-mount-path "${mountdir}"`,
].join(' ');
}
case 'in-memory':
// ACI emptyDir volumes require YAML deployment; for simplicity we skip
// the volume mount and let the container use its own filesystem
OrchestratorLogger.log('[Azure ACI] In-memory mode: using container filesystem (no persistent mount)');
return '';
case 'blob-copy':
// No volume mount — artifacts are copied in/out via az storage blob commands
return '';
default:
return '';
}
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[Azure ACI] Running task for build ${buildGuid}`);
ResourceTracking.logAllocationSummary('azure-aci task');
const containerName = `unity-build-${buildGuid}`
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.slice(0, 63);
// Build environment variable flags
const allEnvVars = [
...environment.map((env) => `${env.name}=${env.value}`),
...secrets.map((s) => `${s.EnvironmentVariable}=${s.ParameterValue}`),
];
const envFlag = allEnvVars.length > 0 ? `--environment-variables ${allEnvVars.map((e) => `"${e}"`).join(' ')}` : '';
// Build volume flags based on storage type
const volumeFlags = await this.buildVolumeFlags(mountdir);
const subnetFlag = this.subnetId ? `--subnet "${this.subnetId}"` : '';
// For blob-copy, wrap the user command with copy-in/copy-out steps
let effectiveCommands = commands;
if (this.storageType === 'blob-copy' && this.storageAccount && commands) {
effectiveCommands = [
`az storage blob download-batch --destination "${mountdir}" --source "${this.blobContainer}" --account-name "${this.storageAccount}" 2>/dev/null || true`,
commands,
`az storage blob upload-batch --source "${mountdir}" --destination "${this.blobContainer}" --account-name "${this.storageAccount}" --overwrite`,
].join(' && ');
}
const commandFlag = effectiveCommands
? `--command-line "/bin/sh -c '${effectiveCommands.replace(/'/g, "'\\''")}'"`
: '';
const createCmd = [
'az container create',
`--resource-group "${this.resourceGroup}"`,
`--name "${containerName}"`,
`--image "${image}"`,
`--location "${this.location}"`,
`--cpu ${this.cpu}`,
`--memory ${this.memoryGb}`,
'--restart-policy Never',
'--os-type Linux',
volumeFlags,
envFlag,
subnetFlag,
commandFlag,
'--output json',
]
.filter(Boolean)
.join(' ');
try {
await OrchestratorSystem.Run(createCmd);
OrchestratorLogger.log(
`[Azure ACI] Container ${containerName} created (storage: ${this.storageType}), waiting for completion...`,
);
} catch (error: any) {
throw new Error(`[Azure ACI] Failed to create container: ${error.message}`);
}
const output = await this.waitForContainerCompletion(containerName);
return output;
}
private async waitForContainerCompletion(containerName: string): Promise<string> {
const maxWaitMs = 24 * 60 * 60 * 1000;
const pollIntervalMs = 15_000;
const startTime = Date.now();
let lastLogLength = 0;
while (Date.now() - startTime < maxWaitMs) {
try {
const stateJson = await OrchestratorSystem.Run(
`az container show --resource-group "${this.resourceGroup}" --name "${containerName}" --output json`,
false,
true,
);
const state = JSON.parse(stateJson);
const containerState =
state.containers?.[0]?.instanceView?.currentState?.state || state.instanceView?.state || 'Unknown';
const provisioningState = state.provisioningState || 'Unknown';
// Stream logs incrementally
try {
const logs = await OrchestratorSystem.Run(
`az container logs --resource-group "${this.resourceGroup}" --name "${containerName}"`,
false,
true,
);
if (logs && logs.length > lastLogLength) {
const newLogs = logs.slice(lastLogLength);
for (const line of newLogs.split('\n')) {
if (line.trim()) {
OrchestratorLogger.log(`[Build] ${line}`);
}
}
lastLogLength = logs.length;
}
} catch {
// Logs may not be available yet
}
if (containerState === 'Terminated' || provisioningState === 'Succeeded') {
const exitCode = state.containers?.[0]?.instanceView?.currentState?.exitCode;
if (exitCode !== undefined && exitCode !== 0) {
throw new Error(`[Azure ACI] Container exited with code ${exitCode}`);
}
OrchestratorLogger.log('[Azure ACI] Container completed successfully');
try {
return await OrchestratorSystem.Run(
`az container logs --resource-group "${this.resourceGroup}" --name "${containerName}"`,
false,
true,
);
} catch {
return '';
}
}
if (provisioningState === 'Failed') {
const detail =
state.containers?.[0]?.instanceView?.currentState?.detailStatus ||
state.containers?.[0]?.instanceView?.events?.map((e: any) => e.message).join('; ') ||
'Unknown error';
throw new Error(`[Azure ACI] Container provisioning failed: ${detail}`);
}
} catch (error: any) {
if (error.message?.includes('Container provisioning failed') || error.message?.includes('exited with code')) {
throw error;
}
OrchestratorLogger.logWarning(`[Azure ACI] Polling error: ${error.message}`);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error('[Azure ACI] Container execution timed out after 24 hours');
}
async cleanupWorkflow(
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
OrchestratorLogger.log('[Azure ACI] Cleaning up workflow');
}
async garbageCollect(
filter: string,
previewOnly: boolean,
olderThan: Number,
fullCache: boolean,
baseDependencies: boolean,
): Promise<string> {
OrchestratorLogger.log('[Azure ACI] Garbage collecting old container groups');
try {
const containersJson = await OrchestratorSystem.Run(
`az container list --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
const containers = JSON.parse(containersJson || '[]');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - Number(olderThan));
let deletedCount = 0;
for (const container of containers) {
const name = container.name || '';
if (!name.startsWith('unity-build-')) continue;
const createdAt = new Date(container.tags?.createdAt || container.properties?.provisioningState || 0);
const state = container.containers?.[0]?.instanceView?.currentState?.state || '';
if (state === 'Terminated' || createdAt < cutoffDate) {
if (previewOnly) {
OrchestratorLogger.log(`[Azure ACI] Would delete: ${name}`);
} else {
await OrchestratorSystem.Run(
`az container delete --resource-group "${this.resourceGroup}" --name "${name}" --yes`,
);
deletedCount++;
}
}
}
return `Garbage collected ${deletedCount} Azure container instances`;
} catch (error: any) {
OrchestratorLogger.logWarning(`[Azure ACI] Garbage collection failed: ${error.message}`);
return '';
}
}
async listResources(): Promise<ProviderResource[]> {
try {
const containersJson = await OrchestratorSystem.Run(
`az container list --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
const containers = JSON.parse(containersJson || '[]');
return containers
.filter((c: any) => (c.name || '').startsWith('unity-build-'))
.map((c: any) => ({ Name: c.name || '' }));
} catch {
return [];
}
}
listWorkflow(): Promise<ProviderWorkflow[]> {
throw new Error('[Azure ACI] listWorkflow not implemented for this experimental provider');
}
async watchWorkflow(): Promise<string> {
throw new Error('[Azure ACI] watchWorkflow not implemented for this experimental provider');
}
}
export default AzureAciProvider;

View File

@@ -1,435 +0,0 @@
/**
* Google Cloud Run Jobs Provider (Experimental)
*
* Executes Unity builds as Cloud Run Jobs with configurable storage backends.
*
* Storage types:
* - gcs-fuse: Mount a GCS bucket as a POSIX filesystem via GCS FUSE sidecar.
* Unlimited size, best for large sequential reads/writes.
* Requires: gcpBucket
* - gcs-copy: Copy artifacts in/out of GCS before/after the build via gsutil.
* No mount overhead, simpler, works everywhere.
* Requires: gcpBucket
* - nfs: Mount a Filestore NFS share. True POSIX semantics, good random I/O,
* up to 100 TiB. Best for Library caching (many small random reads).
* Requires: gcpFilestoreIp, gcpFilestoreShare
* - in-memory: tmpfs volume (emptyDir). Fastest I/O but volatile and limited to 32 GiB.
* Good for scratch/temp space during builds.
*
* Prerequisites:
* - Google Cloud SDK authenticated (GOOGLE_APPLICATION_CREDENTIALS or gcloud auth)
* - Cloud Run Jobs API enabled
* - Service account with roles: Cloud Run Admin, Storage Admin, Logs Viewer
*
* @experimental This provider is experimental. APIs and behavior may change.
*/
import { ProviderInterface } from '../provider-interface';
import BuildParameters from '../../../build-parameters';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import { Input } from '../../..';
import ResourceTracking from '../../services/core/resource-tracking';
type GcpStorageType = 'gcs-fuse' | 'gcs-copy' | 'nfs' | 'in-memory';
class GcpCloudRunProvider implements ProviderInterface {
private readonly project: string;
private readonly region: string;
private readonly storageType: GcpStorageType;
private readonly bucket: string;
private readonly filestoreIp: string;
private readonly filestoreShare: string;
private readonly machineType: string;
private readonly diskSizeGb: number;
private readonly serviceAccount: string;
private readonly vpcConnector: string;
private buildParameters: BuildParameters;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.project = buildParameters.gcpProject || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || '';
this.region = buildParameters.gcpRegion || Input.region || 'us-central1';
this.storageType = (buildParameters.gcpStorageType || 'gcs-fuse') as GcpStorageType;
this.bucket = buildParameters.gcpBucket || '';
this.filestoreIp = buildParameters.gcpFilestoreIp || '';
this.filestoreShare = buildParameters.gcpFilestoreShare || '/share1';
this.machineType = buildParameters.gcpMachineType || 'e2-standard-4';
this.diskSizeGb = Number.parseInt(buildParameters.gcpDiskSizeGb || '100', 10);
this.serviceAccount = buildParameters.gcpServiceAccount || '';
this.vpcConnector = buildParameters.gcpVpcConnector || '';
OrchestratorLogger.log('[GCP Cloud Run] Provider initialized (EXPERIMENTAL)');
OrchestratorLogger.log(`[GCP Cloud Run] Project: ${this.project || '(auto-detect)'}`);
OrchestratorLogger.log(`[GCP Cloud Run] Region: ${this.region}`);
OrchestratorLogger.log(`[GCP Cloud Run] Storage: ${this.storageType}`);
this.validateStorageConfig();
}
private validateStorageConfig(): void {
switch (this.storageType) {
case 'gcs-fuse':
case 'gcs-copy':
if (!this.bucket) {
OrchestratorLogger.logWarning(
`[GCP Cloud Run] Storage type '${this.storageType}' requires gcpBucket to be set.`,
);
} else {
OrchestratorLogger.log(`[GCP Cloud Run] Bucket: gs://${this.bucket}`);
}
break;
case 'nfs':
if (!this.filestoreIp) {
OrchestratorLogger.logWarning('[GCP Cloud Run] Storage type "nfs" requires gcpFilestoreIp to be set.');
} else {
OrchestratorLogger.log(`[GCP Cloud Run] Filestore: ${this.filestoreIp}:${this.filestoreShare}`);
}
if (!this.vpcConnector) {
OrchestratorLogger.logWarning(
'[GCP Cloud Run] NFS storage usually requires gcpVpcConnector for private network access to Filestore.',
);
}
break;
case 'in-memory':
OrchestratorLogger.log(`[GCP Cloud Run] In-memory volume: ${Math.min(this.diskSizeGb, 32)} GiB (max 32)`);
break;
default:
OrchestratorLogger.logWarning(
`[GCP Cloud Run] Unknown storage type '${this.storageType}'. Valid: gcs-fuse, gcs-copy, nfs, in-memory`,
);
}
if (!this.project) {
OrchestratorLogger.logWarning(
'[GCP Cloud Run] No project specified. Set gcpProject input or GOOGLE_CLOUD_PROJECT env var.',
);
}
}
async setupWorkflow(
buildGuid: string,
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
OrchestratorLogger.log(`[GCP Cloud Run] Setting up workflow for build ${buildGuid}`);
ResourceTracking.logAllocationSummary('gcp-cloud-run setup');
// Verify gcloud CLI is available
try {
await OrchestratorSystem.Run('gcloud --version', false, true);
OrchestratorLogger.log('[GCP Cloud Run] gcloud CLI detected');
} catch {
throw new Error(
'[GCP Cloud Run] gcloud CLI not found. Install Google Cloud SDK: https://cloud.google.com/sdk/docs/install',
);
}
// Verify Cloud Run Jobs API is enabled
try {
const projectFlag = this.project ? `--project=${this.project}` : '';
await OrchestratorSystem.Run(
`gcloud services list --enabled --filter="name:run.googleapis.com" ${projectFlag} --format="value(name)"`,
false,
true,
);
} catch {
OrchestratorLogger.logWarning(
'[GCP Cloud Run] Could not verify Cloud Run API status. Ensure run.googleapis.com is enabled.',
);
}
// Storage-specific setup
if ((this.storageType === 'gcs-fuse' || this.storageType === 'gcs-copy') && this.bucket) {
await this.ensureBucketExists();
}
}
private async ensureBucketExists(): Promise<void> {
try {
await OrchestratorSystem.Run(
`gcloud storage buckets describe gs://${this.bucket} --format="value(name)"`,
false,
true,
);
OrchestratorLogger.log(`[GCP Cloud Run] Bucket gs://${this.bucket} exists`);
} catch {
OrchestratorLogger.log(`[GCP Cloud Run] Creating bucket gs://${this.bucket}`);
const projectFlag = this.project ? `--project=${this.project}` : '';
await OrchestratorSystem.Run(
`gcloud storage buckets create gs://${this.bucket} --location=${this.region} ${projectFlag}`,
);
}
}
private buildVolumeFlags(mountdir: string): { volumeFlags: string; mountFlags: string } {
switch (this.storageType) {
case 'gcs-fuse':
if (!this.bucket) return { volumeFlags: '', mountFlags: '' };
return {
volumeFlags: `--add-volume=name=gcs-fuse,type=cloud-storage,bucket=${this.bucket}`,
mountFlags: `--add-volume-mount=volume=gcs-fuse,mount-path=${mountdir}`,
};
case 'nfs':
if (!this.filestoreIp) return { volumeFlags: '', mountFlags: '' };
return {
volumeFlags: `--add-volume=name=nfs-vol,type=nfs,location=${this.filestoreIp}:${this.filestoreShare}`,
mountFlags: `--add-volume-mount=volume=nfs-vol,mount-path=${mountdir}`,
};
case 'in-memory': {
const sizeGib = Math.min(this.diskSizeGb, 32);
return {
volumeFlags: `--add-volume=name=tmpfs-vol,type=in-memory,size-limit=${sizeGib}Gi`,
mountFlags: `--add-volume-mount=volume=tmpfs-vol,mount-path=${mountdir}`,
};
}
case 'gcs-copy':
// No volume mount — artifacts are copied in/out via gsutil commands
return { volumeFlags: '', mountFlags: '' };
default:
return { volumeFlags: '', mountFlags: '' };
}
}
private async copyArtifactsIn(mountdir: string): Promise<void> {
if (this.storageType !== 'gcs-copy' || !this.bucket) return;
OrchestratorLogger.log(`[GCP Cloud Run] Copying artifacts from gs://${this.bucket} to ${mountdir}`);
try {
await OrchestratorSystem.Run(`gcloud storage cp -r "gs://${this.bucket}/*" "${mountdir}/" || true`, false, true);
} catch {
OrchestratorLogger.log('[GCP Cloud Run] No existing artifacts to restore (bucket may be empty)');
}
}
private async copyArtifactsOut(mountdir: string): Promise<void> {
if (this.storageType !== 'gcs-copy' || !this.bucket) return;
OrchestratorLogger.log(`[GCP Cloud Run] Uploading artifacts from ${mountdir} to gs://${this.bucket}`);
await OrchestratorSystem.Run(`gcloud storage cp -r "${mountdir}/*" "gs://${this.bucket}/"`, false, true);
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[GCP Cloud Run] Running task for build ${buildGuid}`);
ResourceTracking.logAllocationSummary('gcp-cloud-run task');
const jobName = `unity-build-${buildGuid}`
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.slice(0, 63);
const projectFlag = this.project ? `--project=${this.project}` : '';
// Build environment variable flags
const envFlags = environment
.map((env) => `${env.name}=${env.value}`)
.concat(secrets.map((s) => `${s.EnvironmentVariable}=${s.ParameterValue}`));
const envString = envFlags.length > 0 ? `--set-env-vars="${envFlags.join(',')}"` : '';
// Build storage volume flags
const { volumeFlags, mountFlags } = this.buildVolumeFlags(mountdir);
// For gcs-copy, wrap the user command with copy-in/copy-out steps
let effectiveCommands = commands;
if (this.storageType === 'gcs-copy' && this.bucket && commands) {
effectiveCommands = [
`gcloud storage cp -r "gs://${this.bucket}/*" "${mountdir}/" 2>/dev/null || true`,
commands,
`gcloud storage cp -r "${mountdir}/*" "gs://${this.bucket}/"`,
].join(' && ');
}
const saFlag = this.serviceAccount ? `--service-account=${this.serviceAccount}` : '';
const vpcFlag = this.vpcConnector ? `--vpc-connector=${this.vpcConnector}` : '';
// Create the Cloud Run Job
const createCmd = [
'gcloud run jobs create',
jobName,
`--image=${image}`,
`--region=${this.region}`,
'--task-timeout=86400s',
'--max-retries=0',
'--cpu=4',
'--memory=16Gi',
volumeFlags,
mountFlags,
envString,
saFlag,
vpcFlag,
projectFlag,
'--format=json',
'--quiet',
]
.filter(Boolean)
.join(' ');
try {
await OrchestratorSystem.Run(createCmd);
OrchestratorLogger.log(`[GCP Cloud Run] Job ${jobName} created`);
} catch (error: any) {
if (error.message?.includes('already exists')) {
OrchestratorLogger.log(`[GCP Cloud Run] Job ${jobName} already exists, updating...`);
const updateCmd = createCmd.replace('jobs create', 'jobs update');
await OrchestratorSystem.Run(updateCmd);
} else {
throw error;
}
}
// Override the command if provided
if (effectiveCommands) {
const updateCmd = [
'gcloud run jobs update',
jobName,
`--region=${this.region}`,
'--command="/bin/sh"',
`--args="-c,${effectiveCommands}"`,
projectFlag,
'--quiet',
]
.filter(Boolean)
.join(' ');
await OrchestratorSystem.Run(updateCmd);
}
// Execute the job
OrchestratorLogger.log(`[GCP Cloud Run] Executing job ${jobName} (storage: ${this.storageType})...`);
const executeCmd = [
'gcloud run jobs execute',
jobName,
`--region=${this.region}`,
projectFlag,
'--wait',
'--format=json',
'--quiet',
]
.filter(Boolean)
.join(' ');
let output = '';
try {
output = await OrchestratorSystem.Run(executeCmd);
OrchestratorLogger.log('[GCP Cloud Run] Job execution completed');
} catch (error: any) {
await this.streamJobLogs(jobName);
throw new Error(`[GCP Cloud Run] Job execution failed: ${error.message}`);
}
await this.streamJobLogs(jobName);
return output;
}
private async streamJobLogs(jobName: string): Promise<void> {
const projectFlag = this.project ? `--project=${this.project}` : '';
try {
const logs = await OrchestratorSystem.Run(
`gcloud logging read "resource.type=cloud_run_job AND resource.labels.job_name=${jobName}" ${projectFlag} --limit=1000 --format="value(textPayload)" --order=asc`,
false,
true,
);
if (logs) {
for (const line of logs.split('\n')) {
if (line.trim()) {
OrchestratorLogger.log(`[Build] ${line}`);
}
}
}
} catch {
OrchestratorLogger.logWarning('[GCP Cloud Run] Could not retrieve job logs');
}
}
async cleanupWorkflow(
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
OrchestratorLogger.log('[GCP Cloud Run] Cleaning up workflow');
}
async garbageCollect(
filter: string,
previewOnly: boolean,
olderThan: Number,
fullCache: boolean,
baseDependencies: boolean,
): Promise<string> {
OrchestratorLogger.log('[GCP Cloud Run] Garbage collecting old jobs');
const projectFlag = this.project ? `--project=${this.project}` : '';
try {
const jobsJson = await OrchestratorSystem.Run(
`gcloud run jobs list --region=${this.region} ${projectFlag} --filter="metadata.name~unity-build-" --format="json(metadata.name,metadata.creationTimestamp)"`,
false,
true,
);
const jobs = JSON.parse(jobsJson || '[]');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - Number(olderThan));
let deletedCount = 0;
for (const job of jobs) {
const createdAt = new Date(job.metadata?.creationTimestamp || 0);
if (createdAt < cutoffDate) {
const name = job.metadata?.name;
if (previewOnly) {
OrchestratorLogger.log(`[GCP Cloud Run] Would delete: ${name}`);
} else {
await OrchestratorSystem.Run(
`gcloud run jobs delete ${name} --region=${this.region} ${projectFlag} --quiet`,
);
deletedCount++;
}
}
}
return `Garbage collected ${deletedCount} Cloud Run jobs`;
} catch (error: any) {
OrchestratorLogger.logWarning(`[GCP Cloud Run] Garbage collection failed: ${error.message}`);
return '';
}
}
async listResources(): Promise<ProviderResource[]> {
const projectFlag = this.project ? `--project=${this.project}` : '';
try {
const jobsJson = await OrchestratorSystem.Run(
`gcloud run jobs list --region=${this.region} ${projectFlag} --filter="metadata.name~unity-build-" --format="json(metadata.name)"`,
false,
true,
);
const jobs = JSON.parse(jobsJson || '[]');
return jobs.map((job: any) => ({ Name: job.metadata?.name || '' }));
} catch {
return [];
}
}
listWorkflow(): Promise<ProviderWorkflow[]> {
throw new Error('[GCP Cloud Run] listWorkflow not implemented for this experimental provider');
}
async watchWorkflow(): Promise<string> {
throw new Error('[GCP Cloud Run] watchWorkflow not implemented for this experimental provider');
}
}
export default GcpCloudRunProvider;

View File

@@ -62,8 +62,6 @@ export default async function loadProvider(
'local-docker': './docker',
'local-system': './local',
local: './local',
'gcp-cloud-run': './gcp-cloud-run',
'azure-aci': './azure-aci',
};
modulePath = providerModuleMap[providerSource] || providerSource;
@@ -138,7 +136,7 @@ export class ProviderLoader {
* @returns string[] - Array of available provider names
*/
static getAvailableProviders(): string[] {
return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local', 'gcp-cloud-run', 'azure-aci'];
return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local'];
}
/**

View File

@@ -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;
});
});
});

View File

@@ -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,
};
}
}

View File

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

View File

@@ -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[];
}

View File

@@ -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;
}
}

View File

@@ -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 = {};
}
}