feat(orchestrator): enterprise feature support — CLI provider, submodule profiles, caching, LFS, hooks

Add generic enterprise-grade features to the orchestrator, enabling Unity projects with
complex CI/CD pipelines to adopt game-ci/unity-builder with built-in support for:

- CLI provider protocol: JSON-over-stdin/stdout bridge enabling providers in any language
  (Go, Python, Rust, shell) via the `providerExecutable` input
- Submodule profiles: YAML-based selective submodule initialization with glob patterns
  and variant overlays (`submoduleProfilePath`, `submoduleVariantPath`)
- Local build caching: Filesystem-based Library and LFS caching for local builds without
  external cache actions (`localCacheEnabled`, `localCacheRoot`)
- Custom LFS transfer agents: Register external transfer agents like elastic-git-storage
  (`lfsTransferAgent`, `lfsTransferAgentArgs`, `lfsStoragePaths`)
- Git hooks support: Detect and install lefthook/husky with configurable skip lists
  (`gitHooksEnabled`, `gitHooksSkipList`)

Also removes all `orchestrator-develop` branch references, replacing with `main`.

13 new action inputs, 13 new files, 14 new CLI provider tests, 17 submodule tests,
plus cache/LFS/hooks unit tests. All 452 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
frostebite
2026-03-05 06:54:46 +00:00
parent 9d475434d3
commit 5268630ef0
27 changed files with 3302 additions and 15 deletions
@@ -0,0 +1,20 @@
export interface CliProviderRequest {
command: CliProviderSubcommand;
params: Record<string, any>;
}
export interface CliProviderResponse {
success: boolean;
result?: any;
error?: string;
output?: string;
}
export type CliProviderSubcommand =
| 'setup-workflow'
| 'cleanup-workflow'
| 'run-task'
| 'garbage-collect'
| 'list-resources'
| 'list-workflow'
| 'watch-workflow';
@@ -0,0 +1,226 @@
import { EventEmitter } from 'events';
import { ProviderLoader } from '../provider-loader';
// Mock child_process
jest.mock('child_process', () => ({
spawn: jest.fn(),
exec: jest.fn(),
}));
// Mock @actions/core to prevent GitHub Actions API calls
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(() => ''),
}));
// Mock provider-git-manager (required by provider-loader)
jest.mock('../provider-git-manager');
import { spawn } from 'child_process';
import CliProvider from './cli-provider';
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
/**
* Creates a mock child process with stdin, stdout, stderr as EventEmitters.
*/
function createMockChildProcess() {
const child = new EventEmitter() as any;
child.stdin = { write: jest.fn(), end: jest.fn() };
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = jest.fn();
return child;
}
describe('CliProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it('validates that executable path is non-empty', () => {
expect(() => new CliProvider('', {} as any)).toThrow('executablePath must be a non-empty string');
});
it('validates that executable path is not just whitespace', () => {
expect(() => new CliProvider(' ', {} as any)).toThrow('executablePath must be a non-empty string');
});
it('accepts a valid executable path', () => {
const provider = new CliProvider('/usr/bin/my-provider', {} as any);
expect(provider).toBeDefined();
});
});
describe('request serialization', () => {
it('sends JSON request to stdin with correct command and params', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listResources();
// Simulate successful response
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: [] }) + '\n'));
child.emit('close', 0);
await promise;
expect(child.stdin.write).toHaveBeenCalledTimes(1);
const writtenData = child.stdin.write.mock.calls[0][0];
const parsed = JSON.parse(writtenData);
expect(parsed.command).toBe('list-resources');
expect(parsed.params).toEqual({});
expect(child.stdin.end).toHaveBeenCalled();
});
it('serializes setupWorkflow params correctly', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.setupWorkflow('guid-123', { editorVersion: '2022.3' } as any, 'main', []);
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: {} }) + '\n'));
child.emit('close', 0);
await promise;
const writtenData = child.stdin.write.mock.calls[0][0];
const parsed = JSON.parse(writtenData);
expect(parsed.command).toBe('setup-workflow');
expect(parsed.params.buildGuid).toBe('guid-123');
expect(parsed.params.branchName).toBe('main');
});
});
describe('response parsing', () => {
it('resolves on successful JSON response', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listResources();
const resources = [{ Name: 'resource-1' }, { Name: 'resource-2' }];
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: resources }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toEqual(resources);
});
it('rejects on error JSON response', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.garbageCollect('', false, 30, false, false);
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: false, error: 'something went wrong' }) + '\n'));
child.emit('close', 1);
await expect(promise).rejects.toThrow('something went wrong');
});
it('rejects when process exits with non-zero code and no JSON response', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listWorkflow();
child.stderr.emit('data', Buffer.from('segfault\n'));
child.emit('close', 139);
await expect(promise).rejects.toThrow('exited with code 139');
});
it('resolves when process exits with code 0 and no JSON response', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listResources();
child.stdout.emit('data', Buffer.from('some plain text output\n'));
child.emit('close', 0);
const result = await promise;
// listResources falls back to empty array when result is missing
expect(result).toEqual([]);
});
it('rejects on spawn error', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/nonexistent/path', {} as any);
const promise = provider.listResources();
child.emit('error', new Error('ENOENT'));
await expect(promise).rejects.toThrow('failed to spawn executable');
});
});
describe('runTaskInWorkflow', () => {
it('forwards non-JSON stdout lines as build output and returns final response', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
// Simulate build output followed by JSON response
child.stdout.emit('data', Buffer.from('Building project...\nCompiling scripts...\n'));
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: 'Build succeeded' }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('Build succeeded');
});
it('rejects on run-task failure', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
child.stdout.emit(
'data',
Buffer.from(JSON.stringify({ success: false, error: 'Build failed: compilation errors' }) + '\n'),
);
child.emit('close', 1);
await expect(promise).rejects.toThrow('Build failed: compilation errors');
});
it('returns collected output lines when no JSON response and exit code 0', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
child.stdout.emit('data', Buffer.from('line 1\nline 2\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('line 1\nline 2');
});
});
describe('available providers list', () => {
it('includes cli in the available providers', () => {
const providers = ProviderLoader.getAvailableProviders();
expect(providers).toContain('cli');
});
});
});
@@ -0,0 +1,344 @@
import { spawn } from 'child_process';
import { ProviderInterface } from '../provider-interface';
import BuildParameters from '../../../build-parameters';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { CliProviderRequest, CliProviderResponse, CliProviderSubcommand } from './cli-provider-protocol';
const DEFAULT_TIMEOUT_MS = 300_000; // 300 seconds
class CliProvider implements ProviderInterface {
private readonly executablePath: string;
private readonly buildParameters: BuildParameters;
constructor(executablePath: string, buildParameters: BuildParameters) {
if (!executablePath || executablePath.trim() === '') {
throw new Error('CliProvider: executablePath must be a non-empty string');
}
this.executablePath = executablePath;
this.buildParameters = buildParameters;
}
async setupWorkflow(
buildGuid: string,
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<any> {
const response = await this.execute('setup-workflow', {
buildGuid,
buildParameters,
branchName,
defaultSecretsArray,
});
return response.result;
}
async cleanupWorkflow(
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<any> {
const response = await this.execute('cleanup-workflow', {
buildParameters,
branchName,
defaultSecretsArray,
});
return response.result;
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
const request: CliProviderRequest = {
command: 'run-task',
params: {
buildGuid,
image,
commands,
mountdir,
workingdir,
environment,
secrets,
},
};
return new Promise<string>((resolve, reject) => {
const child = spawn(this.executablePath, ['run-task'], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
let lastJsonResponse: CliProviderResponse | undefined;
const outputLines: string[] = [];
let stderrOutput = '';
child.stdin.write(JSON.stringify(request));
child.stdin.end();
child.stdout.on('data', (data: Buffer) => {
const lines = data.toString().split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Try to parse as JSON response
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
lastJsonResponse = parsed as CliProviderResponse;
continue;
}
} catch {
// Not JSON — treat as build output
}
// Forward non-JSON lines as real-time build output
OrchestratorLogger.log(trimmed);
outputLines.push(trimmed);
}
});
child.stderr.on('data', (data: Buffer) => {
const text = data.toString();
stderrOutput += text;
// Forward stderr to logger
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (trimmed) {
OrchestratorLogger.log(`[cli-provider stderr] ${trimmed}`);
}
}
});
child.on('error', (error: Error) => {
reject(new Error(`CliProvider: failed to spawn executable '${this.executablePath}': ${error.message}`));
});
child.on('close', (code: number | null) => {
if (lastJsonResponse) {
if (lastJsonResponse.success) {
resolve(lastJsonResponse.output || outputLines.join('\n'));
} else {
reject(
new Error(`CliProvider run-task failed: ${lastJsonResponse.error || 'Unknown error from CLI provider'}`),
);
}
} else if (code === 0) {
resolve(outputLines.join('\n'));
} else {
reject(
new Error(`CliProvider run-task exited with code ${code}${stderrOutput ? ': ' + stderrOutput.trim() : ''}`),
);
}
});
});
}
async garbageCollect(
filter: string,
previewOnly: boolean,
olderThan: Number,
fullCache: boolean,
baseDependencies: boolean,
): Promise<string> {
const response = await this.execute('garbage-collect', {
filter,
previewOnly,
olderThan,
fullCache,
baseDependencies,
});
return response.output || '';
}
async listResources(): Promise<ProviderResource[]> {
const response = await this.execute('list-resources', {});
return (response.result as ProviderResource[]) || [];
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
const response = await this.execute('list-workflow', {});
return (response.result as ProviderWorkflow[]) || [];
}
async watchWorkflow(): Promise<string> {
const request: CliProviderRequest = {
command: 'watch-workflow',
params: {},
};
return new Promise<string>((resolve, reject) => {
const child = spawn(this.executablePath, ['watch-workflow'], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
let lastJsonResponse: CliProviderResponse | undefined;
const outputLines: string[] = [];
child.stdin.write(JSON.stringify(request));
child.stdin.end();
child.stdout.on('data', (data: Buffer) => {
const lines = data.toString().split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
lastJsonResponse = parsed as CliProviderResponse;
continue;
}
} catch {
// Not JSON
}
OrchestratorLogger.log(trimmed);
outputLines.push(trimmed);
}
});
child.stderr.on('data', (data: Buffer) => {
for (const line of data.toString().split('\n')) {
const trimmed = line.trim();
if (trimmed) {
OrchestratorLogger.log(`[cli-provider stderr] ${trimmed}`);
}
}
});
child.on('error', (error: Error) => {
reject(new Error(`CliProvider: failed to spawn executable '${this.executablePath}': ${error.message}`));
});
child.on('close', (code: number | null) => {
if (lastJsonResponse) {
if (lastJsonResponse.success) {
resolve(lastJsonResponse.output || outputLines.join('\n'));
} else {
reject(new Error(`CliProvider watch-workflow failed: ${lastJsonResponse.error || 'Unknown error'}`));
}
} else if (code === 0) {
resolve(outputLines.join('\n'));
} else {
reject(new Error(`CliProvider watch-workflow exited with code ${code}`));
}
});
});
}
/**
* Execute a CLI provider subcommand with a default timeout.
* Used for all methods except runTaskInWorkflow and watchWorkflow (which have no timeout).
*/
private execute(
command: CliProviderSubcommand,
params: Record<string, any>,
timeoutMs: number = DEFAULT_TIMEOUT_MS,
): Promise<CliProviderResponse> {
const request: CliProviderRequest = { command, params };
return new Promise<CliProviderResponse>((resolve, reject) => {
const child = spawn(this.executablePath, [command], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
let stdoutData = '';
let stderrData = '';
let timedOut = false;
// Set up timeout
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
reject(new Error(`CliProvider: command '${command}' timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdin.write(JSON.stringify(request));
child.stdin.end();
child.stdout.on('data', (data: Buffer) => {
stdoutData += data.toString();
});
child.stderr.on('data', (data: Buffer) => {
const text = data.toString();
stderrData += text;
// Forward stderr to logger
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (trimmed) {
OrchestratorLogger.log(`[cli-provider stderr] ${trimmed}`);
}
}
});
child.on('error', (error: Error) => {
clearTimeout(timer);
if (!timedOut) {
reject(new Error(`CliProvider: failed to spawn executable '${this.executablePath}': ${error.message}`));
}
});
child.on('close', (code: number | null) => {
clearTimeout(timer);
if (timedOut) return;
// Find the last JSON line in stdout
const lines = stdoutData.split('\n').filter((l) => l.trim());
let response: CliProviderResponse | undefined;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i].trim());
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
response = parsed as CliProviderResponse;
break;
}
} catch {
// Not valid JSON, skip
}
}
if (response) {
if (response.success) {
resolve(response);
} else {
reject(new Error(`CliProvider ${command} failed: ${response.error || 'Unknown error from CLI provider'}`));
}
} else if (code === 0) {
// No JSON response but exit code 0 — treat as success with raw output
resolve({ success: true, output: stdoutData.trim() });
} else {
reject(
new Error(
`CliProvider ${command} exited with code ${code}` +
(stderrData ? `: ${stderrData.trim()}` : '') +
(!stderrData && stdoutData ? `: ${stdoutData.trim()}` : ''),
),
);
}
});
});
}
}
export default CliProvider;
@@ -0,0 +1 @@
export { default } from './cli-provider';
@@ -58,6 +58,7 @@ export default async function loadProvider(
const providerModuleMap: Record<string, string> = {
aws: './aws',
k8s: './k8s',
cli: './cli',
test: './test',
'local-docker': './docker',
'local-system': './local',
@@ -136,7 +137,7 @@ export class ProviderLoader {
* @returns string[] - Array of available provider names
*/
static getAvailableProviders(): string[] {
return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local'];
return ['aws', 'k8s', 'cli', 'test', 'local-docker', 'local-system', 'local'];
}
/**