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

View File

@@ -54,7 +54,7 @@ jobs:
# AWS_STACK_NAME: game-ci-github-pipelines
CHECKS_UPDATE: ${{ github.event.inputs.checksObject }}
run: |
git clone -b orchestrator-develop https://github.com/game-ci/unity-builder
git clone -b main https://github.com/game-ci/unity-builder
cd unity-builder
yarn
ls

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
@@ -279,6 +279,76 @@ inputs:
description:
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
fixes.'
submoduleProfilePath:
required: false
default: ''
description:
'Path to a YAML submodule profile file (relative to repo root). Defines which submodules to initialize (branch:
main) or skip (branch: empty). See docs for format.'
submoduleVariantPath:
required: false
default: ''
description:
'Path to a YAML variant overlay file that modifies the base submodule profile. Used for server or debug build
variants.'
submoduleToken:
required: false
default: ''
description:
'Git token for authenticating submodule clones. Falls back to gitPrivateToken or GITHUB_TOKEN if empty.'
localCacheEnabled:
required: false
default: 'false'
description:
'Enable filesystem-based caching for local builds. Caches the Unity Library folder and optionally LFS objects
between builds without requiring actions/cache.'
localCacheRoot:
required: false
default: ''
description:
'Root directory for local build cache. Defaults to $RUNNER_TEMP/game-ci-cache or .game-ci/cache if RUNNER_TEMP is
not set.'
localCacheLibrary:
required: false
default: 'true'
description: 'Cache the Unity Library folder for local builds. Only effective when localCacheEnabled is true.'
localCacheLfs:
required: false
default: 'false'
description: 'Cache Git LFS objects for local builds. Only effective when localCacheEnabled is true.'
lfsTransferAgent:
required: false
default: ''
description:
'Path to a custom Git LFS transfer agent executable (e.g. elastic-git-storage). When set, the agent is registered
via git config before LFS operations.'
lfsTransferAgentArgs:
required: false
default: ''
description: 'Additional arguments to pass to the custom LFS transfer agent.'
lfsStoragePaths:
required: false
default: ''
description:
'Semicolon-separated list of storage paths for the custom LFS transfer agent. Interpretation depends on the agent
(e.g. local paths, WebDAV URLs, rclone remotes).'
gitHooksEnabled:
required: false
default: 'false'
description:
'Install and run git hooks (lefthook, husky, or native) during builds. When false (default), hooks are disabled
for build performance.'
gitHooksSkipList:
required: false
default: ''
description:
'Comma-separated list of hook names to skip even when gitHooksEnabled is true. Example: pre-push,post-merge'
providerExecutable:
required: false
default: ''
description:
'Path to an external CLI executable that implements the provider protocol. Enables providers written in any
language (Go, Python, Rust, shell). Uses JSON-over-stdin/stdout communication.'
outputs:
volume:

1086
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,4 +1,5 @@
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';
@@ -23,6 +24,70 @@ async function runMain() {
if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
// Submodule profile initialization
if (buildParameters.submoduleProfilePath) {
const { SubmoduleProfileService } = await import(
'./model/orchestrator/services/submodule/submodule-profile-service'
);
core.info('Initializing submodules from profile...');
const plan = await SubmoduleProfileService.createInitPlan(
buildParameters.submoduleProfilePath,
buildParameters.submoduleVariantPath,
workspace,
);
await SubmoduleProfileService.execute(
plan,
workspace,
buildParameters.submoduleToken || buildParameters.gitPrivateToken,
);
}
// Configure custom LFS transfer agent
if (buildParameters.lfsTransferAgent) {
const { LfsAgentService } = await import('./model/orchestrator/services/lfs/lfs-agent-service');
core.info('Configuring custom LFS transfer agent...');
await LfsAgentService.configure(
buildParameters.lfsTransferAgent,
buildParameters.lfsTransferAgentArgs,
buildParameters.lfsStoragePaths ? buildParameters.lfsStoragePaths.split(';') : [],
workspace,
);
}
// Local build caching - restore
let cacheRoot = '';
let cacheKey = '';
if (buildParameters.localCacheEnabled) {
const { LocalCacheService } = await import('./model/orchestrator/services/cache/local-cache-service');
cacheRoot = LocalCacheService.resolveCacheRoot(buildParameters);
cacheKey = LocalCacheService.generateCacheKey(
buildParameters.targetPlatform,
buildParameters.editorVersion,
buildParameters.branch || '',
);
if (buildParameters.localCacheLfs) {
await LocalCacheService.restoreLfsCache(workspace, cacheRoot, cacheKey);
}
if (buildParameters.localCacheLibrary) {
const projectFullPath = path.join(workspace, buildParameters.projectPath);
await LocalCacheService.restoreLibraryCache(projectFullPath, cacheRoot, cacheKey);
}
}
// Git hooks
if (buildParameters.gitHooksEnabled) {
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
await GitHooksService.installHooks(workspace);
if (buildParameters.gitHooksSkipList) {
const env = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
Object.assign(process.env, env);
}
} else {
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
await GitHooksService.disableHooks(workspace);
}
await PlatformSetup.setup(buildParameters, actionFolder);
exitCode =
process.platform === 'darwin'
@@ -32,6 +97,18 @@ async function runMain() {
actionFolder,
...buildParameters,
});
// Local build caching - save
if (buildParameters.localCacheEnabled) {
const { LocalCacheService } = await import('./model/orchestrator/services/cache/local-cache-service');
if (buildParameters.localCacheLibrary) {
const projectFullPath = path.join(workspace, buildParameters.projectPath);
await LocalCacheService.saveLibraryCache(projectFullPath, cacheRoot, cacheKey);
}
if (buildParameters.localCacheLfs) {
await LocalCacheService.saveLfsCache(workspace, cacheRoot, cacheKey);
}
}
} else {
await Orchestrator.run(buildParameters, baseImage.toString());
exitCode = 0;

View File

@@ -106,6 +106,19 @@ class BuildParameters {
public cacheUnityInstallationOnMac!: boolean;
public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: string;
public submoduleProfilePath!: string;
public submoduleVariantPath!: string;
public submoduleToken!: string;
public localCacheEnabled!: boolean;
public localCacheRoot!: string;
public localCacheLibrary!: boolean;
public localCacheLfs!: boolean;
public lfsTransferAgent!: string;
public lfsTransferAgentArgs!: string;
public lfsStoragePaths!: string;
public gitHooksEnabled!: boolean;
public gitHooksSkipList!: string;
public providerExecutable!: string;
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
@@ -242,6 +255,19 @@ class BuildParameters {
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath,
submoduleProfilePath: Input.submoduleProfilePath,
submoduleVariantPath: Input.submoduleVariantPath,
submoduleToken: Input.submoduleToken,
localCacheEnabled: Input.localCacheEnabled,
localCacheRoot: Input.localCacheRoot,
localCacheLibrary: Input.localCacheLibrary,
localCacheLfs: Input.localCacheLfs,
lfsTransferAgent: Input.lfsTransferAgent,
lfsTransferAgentArgs: Input.lfsTransferAgentArgs,
lfsStoragePaths: Input.lfsStoragePaths,
gitHooksEnabled: Input.gitHooksEnabled,
gitHooksSkipList: Input.gitHooksSkipList,
providerExecutable: Input.providerExecutable,
};
}

View File

@@ -12,6 +12,8 @@ import OrchestratorOptionsReader from '../orchestrator/options/orchestrator-opti
import GitHub from '../github';
import { OptionValues } from 'commander';
import { InputKey } from '../input';
import { SubmoduleProfileService } from '../orchestrator/services/submodule/submodule-profile-service';
import { LfsAgentService } from '../orchestrator/services/lfs/lfs-agent-service';
export class Cli {
public static options: OptionValues | undefined;
@@ -53,6 +55,11 @@ export class Cli {
program.option('--artifactName <artifactName>', 'caching artifact name');
program.option('--select <select>', 'select a particular resource');
program.option('--logFile <logFile>', 'output to log file (log stream only)');
program.option('--profilePath <profilePath>', 'path to submodule profile YAML');
program.option('--variantPath <variantPath>', 'path to submodule variant YAML');
program.option('--agentPath <agentPath>', 'path to custom LFS transfer agent');
program.option('--agentArgs <agentArgs>', 'arguments for custom LFS transfer agent');
program.option('--storagePaths <storagePaths>', 'semicolon-separated storage paths for LFS agent');
program.parse(process.argv);
Cli.options = program.opts();
@@ -172,4 +179,26 @@ export class Cli {
return await Orchestrator.Provider.watchWorkflow();
}
@CliFunction(`submodule-init`, `initializes submodules from a YAML profile`)
public static async SubmoduleInit(): Promise<void> {
const profilePath = Cli.options!['profilePath'];
const variantPath = Cli.options!['variantPath'] || '';
if (!profilePath) {
throw new Error('--profilePath is required for submodule-init');
}
const plan = await SubmoduleProfileService.createInitPlan(profilePath, variantPath, process.cwd());
await SubmoduleProfileService.execute(plan, process.cwd());
}
@CliFunction(`lfs-agent-configure`, `configures a custom LFS transfer agent`)
public static async LfsAgentConfigure(): Promise<void> {
const agentPath = Cli.options!['agentPath'];
if (!agentPath) {
throw new Error('--agentPath is required for lfs-agent-configure');
}
const agentArgs = Cli.options!['agentArgs'] || '';
const storagePaths = (Cli.options!['storagePaths'] || '').split(';').filter(Boolean);
await LfsAgentService.configure(agentPath, agentArgs, storagePaths, process.cwd());
}
}

View File

@@ -282,6 +282,58 @@ class Input {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
static get submoduleProfilePath(): string {
return Input.getInput('submoduleProfilePath') ?? '';
}
static get submoduleVariantPath(): string {
return Input.getInput('submoduleVariantPath') ?? '';
}
static get submoduleToken(): string {
return Input.getInput('submoduleToken') ?? '';
}
static get localCacheEnabled(): boolean {
return (Input.getInput('localCacheEnabled') ?? 'false') === 'true';
}
static get localCacheRoot(): string {
return Input.getInput('localCacheRoot') ?? '';
}
static get localCacheLibrary(): boolean {
return (Input.getInput('localCacheLibrary') ?? 'true') === 'true';
}
static get localCacheLfs(): boolean {
return (Input.getInput('localCacheLfs') ?? 'false') === 'true';
}
static get lfsTransferAgent(): string {
return Input.getInput('lfsTransferAgent') ?? '';
}
static get lfsTransferAgentArgs(): string {
return Input.getInput('lfsTransferAgentArgs') ?? '';
}
static get lfsStoragePaths(): string {
return Input.getInput('lfsStoragePaths') ?? '';
}
static get gitHooksEnabled(): boolean {
return (Input.getInput('gitHooksEnabled') ?? 'false') === 'true';
}
static get gitHooksSkipList(): string {
return Input.getInput('gitHooksSkipList') ?? '';
}
static get providerExecutable(): string {
return Input.getInput('providerExecutable') ?? '';
}
public static ToEnvVarFormat(input: string) {
if (input.toUpperCase() === input) {
return input;

View File

@@ -129,6 +129,17 @@ class Orchestrator {
// Store whether we should validate AWS templates (used by aws-local mode)
Orchestrator.validateAwsTemplates = validateAwsTemplates;
// Check for CLI provider executable
if (Orchestrator.buildParameters.providerExecutable) {
const { default: CliProvider } = await import('./providers/cli');
Orchestrator.Provider = new CliProvider(
Orchestrator.buildParameters.providerExecutable,
Orchestrator.buildParameters,
);
OrchestratorLogger.log(`Using CLI provider executable: ${Orchestrator.buildParameters.providerExecutable}`);
return;
}
switch (provider) {
case 'k8s':
Orchestrator.Provider = new Kubernetes(Orchestrator.buildParameters);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { default } from './cli-provider';

View File

@@ -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'];
}
/**

View File

@@ -237,6 +237,23 @@ export class RemoteClient {
`mkdir -p ${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.cacheFolderForCacheKeyFull)}`,
);
await RemoteClient.cloneRepoWithoutLFSFiles();
// Initialize submodules from profile if configured
if (Orchestrator.buildParameters.submoduleProfilePath) {
const { SubmoduleProfileService } = await import('../services/submodule/submodule-profile-service');
RemoteClientLogger.log('Initializing submodules from profile...');
const plan = await SubmoduleProfileService.createInitPlan(
Orchestrator.buildParameters.submoduleProfilePath,
Orchestrator.buildParameters.submoduleVariantPath,
OrchestratorFolders.repoPathAbsolute,
);
await SubmoduleProfileService.execute(
plan,
OrchestratorFolders.repoPathAbsolute,
Orchestrator.buildParameters.submoduleToken || Orchestrator.buildParameters.gitPrivateToken,
);
}
await RemoteClient.sizeOfFolder(
'repo before lfs cache pull',
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.repoPathAbsolute),
@@ -251,6 +268,19 @@ export class RemoteClient {
`${lfsHashes.lfsGuidSum}`,
);
await RemoteClient.sizeOfFolder('repo after lfs cache pull', OrchestratorFolders.repoPathAbsolute);
// Configure custom LFS transfer agent if specified
if (Orchestrator.buildParameters.lfsTransferAgent) {
const { LfsAgentService } = await import('../services/lfs/lfs-agent-service');
RemoteClientLogger.log('Configuring custom LFS transfer agent...');
await LfsAgentService.configure(
Orchestrator.buildParameters.lfsTransferAgent,
Orchestrator.buildParameters.lfsTransferAgentArgs,
Orchestrator.buildParameters.lfsStoragePaths ? Orchestrator.buildParameters.lfsStoragePaths.split(';') : [],
OrchestratorFolders.repoPathAbsolute,
);
}
await RemoteClient.pullLatestLFS();
await RemoteClient.sizeOfFolder('repo before lfs git pull', OrchestratorFolders.repoPathAbsolute);
await Caching.PushToCache(

View File

@@ -0,0 +1,124 @@
import fs from 'node:fs';
import path from 'node:path';
import { LocalCacheService } from './local-cache-service';
// Mock dependencies
jest.mock('node:fs');
jest.mock('../core/orchestrator-system', () => ({
OrchestratorSystem: {
Run: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
error: jest.fn(),
},
}));
const mockFs = fs as jest.Mocked<typeof fs>;
describe('LocalCacheService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('generateCacheKey', () => {
it('should generate a key from platform, version, and branch', () => {
const key = LocalCacheService.generateCacheKey('StandaloneLinux64', '2021.3.1f1', 'main');
expect(key).toBe('StandaloneLinux64-2021_3_1f1-main');
});
it('should sanitize non-alphanumeric characters except hyphens', () => {
const key = LocalCacheService.generateCacheKey('WebGL', '2022.3.0f1', 'feature/my-branch');
expect(key).toBe('WebGL-2022_3_0f1-feature_my-branch');
});
it('should handle empty branch', () => {
const key = LocalCacheService.generateCacheKey('StandaloneWindows64', '2021.3.1f1', '');
expect(key).toBe('StandaloneWindows64-2021_3_1f1-');
});
});
describe('resolveCacheRoot', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should use localCacheRoot when set', () => {
const result = LocalCacheService.resolveCacheRoot({ localCacheRoot: '/custom/cache' });
expect(result).toBe('/custom/cache');
});
it('should use RUNNER_TEMP when localCacheRoot is empty', () => {
process.env.RUNNER_TEMP = '/tmp/runner';
const result = LocalCacheService.resolveCacheRoot({ localCacheRoot: '' });
expect(result).toBe(path.join('/tmp/runner', 'game-ci-cache'));
});
it('should fall back to .game-ci/cache when neither is set', () => {
delete process.env.RUNNER_TEMP;
const result = LocalCacheService.resolveCacheRoot({ localCacheRoot: '' });
expect(result).toBe(path.join(process.cwd(), '.game-ci', 'cache'));
});
});
describe('restoreLibraryCache', () => {
it('should return false on cache miss (directory does not exist)', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = await LocalCacheService.restoreLibraryCache('/project', '/cache', 'key1');
expect(result).toBe(false);
});
it('should return false when cache directory has no tar files', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['readme.txt', 'info.json']);
const result = await LocalCacheService.restoreLibraryCache('/project', '/cache', 'key1');
expect(result).toBe(false);
});
});
describe('saveLibraryCache', () => {
it('should skip save when Library folder does not exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
await LocalCacheService.saveLibraryCache('/project', '/cache', 'key1');
// Should not throw, just log and return
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
});
it('should create cache directory structure', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockImplementation((dirPath: string) => {
if (String(dirPath).includes('Library') && !String(dirPath).includes('cache')) {
return ['file1.asset', 'file2.asset'];
}
return [];
});
(mockFs.statSync as jest.Mock).mockReturnValue({ mtimeMs: Date.now() });
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('');
await LocalCacheService.saveLibraryCache('/project', '/cache', 'key1');
expect(mockFs.mkdirSync).toHaveBeenCalledWith(path.join('/cache', 'key1', 'Library'), { recursive: true });
});
});
describe('garbageCollect', () => {
it('should skip when cache root does not exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
await LocalCacheService.garbageCollect('/nonexistent');
// Should not throw
});
});
});

View File

@@ -0,0 +1,273 @@
import fs from 'node:fs';
import path from 'node:path';
import { OrchestratorSystem } from '../core/orchestrator-system';
import OrchestratorLogger from '../core/orchestrator-logger';
export class LocalCacheService {
/**
* Resolve the cache root directory based on build parameters and environment.
* Priority: localCacheRoot > RUNNER_TEMP/game-ci-cache > .game-ci/cache
*/
static resolveCacheRoot(buildParameters: { localCacheRoot: string }): string {
if (buildParameters.localCacheRoot) {
return buildParameters.localCacheRoot;
}
if (process.env.RUNNER_TEMP) {
return path.join(process.env.RUNNER_TEMP, 'game-ci-cache');
}
return path.join(process.cwd(), '.game-ci', 'cache');
}
/**
* Generate a sanitized cache key from build parameters.
* Non-alphanumeric characters (except hyphens) are replaced with underscores.
*/
static generateCacheKey(targetPlatform: string, unityVersion: string, branch: string): string {
const raw = `${targetPlatform}-${unityVersion}-${branch}`;
return raw.replace(/[^a-zA-Z0-9-]/g, '_');
}
/**
* Restore Unity Library cache from the local filesystem.
* Returns true if cache was restored, false on cache miss.
*/
static async restoreLibraryCache(projectPath: string, cacheRoot: string, cacheKey: string): Promise<boolean> {
const cachePath = path.join(cacheRoot, cacheKey, 'Library');
try {
if (!fs.existsSync(cachePath)) {
OrchestratorLogger.log(`[LocalCache] Library cache miss: ${cachePath}`);
return false;
}
const files = fs.readdirSync(cachePath).filter((f) => f.endsWith('.tar'));
if (files.length === 0) {
OrchestratorLogger.log(`[LocalCache] Library cache miss (no tar files): ${cachePath}`);
return false;
}
// Find the latest tar file by modification time
let latestFile = files[0];
let latestMtime = fs.statSync(path.join(cachePath, files[0])).mtimeMs;
for (let i = 1; i < files.length; i++) {
const mtime = fs.statSync(path.join(cachePath, files[i])).mtimeMs;
if (mtime > latestMtime) {
latestMtime = mtime;
latestFile = files[i];
}
}
const tarPath = path.join(cachePath, latestFile);
const libraryDest = path.join(projectPath, 'Library');
// Ensure destination exists
fs.mkdirSync(libraryDest, { recursive: true });
OrchestratorLogger.log(`[LocalCache] Library cache hit: restoring from ${tarPath}`);
await OrchestratorSystem.Run(`tar -xf "${tarPath}" -C "${projectPath}"`, true);
OrchestratorLogger.log(`[LocalCache] Library cache restored successfully`);
return true;
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] Library cache restore failed: ${error.message}`);
return false;
}
}
/**
* Save Unity Library folder to the local cache as a tar archive.
* Keeps only the latest 2 cache entries.
*/
static async saveLibraryCache(projectPath: string, cacheRoot: string, cacheKey: string): Promise<void> {
const libraryPath = path.join(projectPath, 'Library');
try {
if (!fs.existsSync(libraryPath)) {
OrchestratorLogger.log(`[LocalCache] Library folder does not exist, skipping save`);
return;
}
const entries = fs.readdirSync(libraryPath);
if (entries.length === 0) {
OrchestratorLogger.log(`[LocalCache] Library folder is empty, skipping save`);
return;
}
const cachePath = path.join(cacheRoot, cacheKey, 'Library');
fs.mkdirSync(cachePath, { recursive: true });
const timestamp = Date.now();
const tarName = `lib-${timestamp}.tar`;
const tarPath = path.join(cachePath, tarName);
OrchestratorLogger.log(`[LocalCache] Saving Library cache to ${tarPath}`);
await OrchestratorSystem.Run(`tar -cf "${tarPath}" -C "${projectPath}" Library`, true);
OrchestratorLogger.log(`[LocalCache] Library cache saved successfully`);
// Clean up old entries - keep latest 2
await LocalCacheService.cleanupOldEntries(cachePath, 2);
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] Library cache save failed: ${error.message}`);
}
}
/**
* Restore LFS cache from the local filesystem.
* Returns true if cache was restored, false on cache miss.
*/
static async restoreLfsCache(repoPath: string, cacheRoot: string, cacheKey: string): Promise<boolean> {
const cachePath = path.join(cacheRoot, cacheKey, 'lfs');
try {
if (!fs.existsSync(cachePath)) {
OrchestratorLogger.log(`[LocalCache] LFS cache miss: ${cachePath}`);
return false;
}
const files = fs.readdirSync(cachePath).filter((f) => f.endsWith('.tar'));
if (files.length === 0) {
OrchestratorLogger.log(`[LocalCache] LFS cache miss (no tar files): ${cachePath}`);
return false;
}
// Find the latest tar file by modification time
let latestFile = files[0];
let latestMtime = fs.statSync(path.join(cachePath, files[0])).mtimeMs;
for (let i = 1; i < files.length; i++) {
const mtime = fs.statSync(path.join(cachePath, files[i])).mtimeMs;
if (mtime > latestMtime) {
latestMtime = mtime;
latestFile = files[i];
}
}
const tarPath = path.join(cachePath, latestFile);
const lfsDest = path.join(repoPath, '.git', 'lfs');
// Ensure destination exists
fs.mkdirSync(lfsDest, { recursive: true });
OrchestratorLogger.log(`[LocalCache] LFS cache hit: restoring from ${tarPath}`);
await OrchestratorSystem.Run(`tar -xf "${tarPath}" -C "${path.join(repoPath, '.git')}"`, true);
OrchestratorLogger.log(`[LocalCache] LFS cache restored successfully`);
return true;
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] LFS cache restore failed: ${error.message}`);
return false;
}
}
/**
* Save .git/lfs folder to the local cache as a tar archive.
* Keeps only the latest 2 cache entries.
*/
static async saveLfsCache(repoPath: string, cacheRoot: string, cacheKey: string): Promise<void> {
const lfsPath = path.join(repoPath, '.git', 'lfs');
try {
if (!fs.existsSync(lfsPath)) {
OrchestratorLogger.log(`[LocalCache] LFS folder does not exist, skipping save`);
return;
}
const entries = fs.readdirSync(lfsPath);
if (entries.length === 0) {
OrchestratorLogger.log(`[LocalCache] LFS folder is empty, skipping save`);
return;
}
const cachePath = path.join(cacheRoot, cacheKey, 'lfs');
fs.mkdirSync(cachePath, { recursive: true });
const timestamp = Date.now();
const tarName = `lfs-${timestamp}.tar`;
const tarPath = path.join(cachePath, tarName);
OrchestratorLogger.log(`[LocalCache] Saving LFS cache to ${tarPath}`);
await OrchestratorSystem.Run(`tar -cf "${tarPath}" -C "${path.join(repoPath, '.git')}" lfs`, true);
OrchestratorLogger.log(`[LocalCache] LFS cache saved successfully`);
// Clean up old entries - keep latest 2
await LocalCacheService.cleanupOldEntries(cachePath, 2);
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] LFS cache save failed: ${error.message}`);
}
}
/**
* Remove cache entries older than maxAgeDays from the cache root.
*/
static async garbageCollect(cacheRoot: string, maxAgeDays: number = 7): Promise<void> {
try {
if (!fs.existsSync(cacheRoot)) {
OrchestratorLogger.log(`[LocalCache] Cache root does not exist, nothing to collect`);
return;
}
const now = Date.now();
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
const entries = fs.readdirSync(cacheRoot);
let removedCount = 0;
for (const entry of entries) {
const entryPath = path.join(cacheRoot, entry);
try {
const stat = fs.statSync(entryPath);
if (stat.isDirectory() && now - stat.mtimeMs > maxAgeMs) {
fs.rmSync(entryPath, { recursive: true, force: true });
removedCount++;
OrchestratorLogger.log(`[LocalCache] Garbage collected: ${entryPath}`);
}
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] Failed to garbage collect ${entryPath}: ${error.message}`);
}
}
OrchestratorLogger.log(`[LocalCache] Garbage collection complete: ${removedCount} entries removed`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] Garbage collection failed: ${error.message}`);
}
}
/**
* Clean up old tar files in a cache directory, keeping only the latest N.
*/
private static async cleanupOldEntries(cachePath: string, keepCount: number): Promise<void> {
try {
const files = fs
.readdirSync(cachePath)
.filter((f) => f.endsWith('.tar'))
.map((f) => ({
name: f,
mtime: fs.statSync(path.join(cachePath, f)).mtimeMs,
}))
.sort((a, b) => b.mtime - a.mtime);
if (files.length > keepCount) {
const toRemove = files.slice(keepCount);
for (const file of toRemove) {
const filePath = path.join(cachePath, file.name);
fs.unlinkSync(filePath);
OrchestratorLogger.log(`[LocalCache] Cleaned up old cache entry: ${filePath}`);
}
}
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] Cleanup of old entries failed: ${error.message}`);
}
}
}

View File

@@ -0,0 +1,121 @@
import fs from 'node:fs';
import path from 'node:path';
import { GitHooksService } from './git-hooks-service';
// Mock dependencies
jest.mock('node:fs');
jest.mock('../core/orchestrator-system', () => ({
OrchestratorSystem: {
Run: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
error: jest.fn(),
},
}));
const mockFs = fs as jest.Mocked<typeof fs>;
describe('GitHooksService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('detectHookFramework', () => {
it('should detect lefthook.yml', () => {
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
});
const result = GitHooksService.detectHookFramework('/repo');
expect(result).toBe('lefthook');
});
it('should detect .lefthook.yml', () => {
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('.lefthook.yml');
});
const result = GitHooksService.detectHookFramework('/repo');
expect(result).toBe('lefthook');
});
it('should detect .husky directory', () => {
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).endsWith('.husky');
});
const result = GitHooksService.detectHookFramework('/repo');
expect(result).toBe('husky');
});
it('should return none when no framework is detected', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = GitHooksService.detectHookFramework('/repo');
expect(result).toBe('none');
});
});
describe('configureSkipList', () => {
it('should return empty object for empty skip list', () => {
const result = GitHooksService.configureSkipList([]);
expect(result).toEqual({});
});
it('should return LEFTHOOK_EXCLUDE with comma-separated hooks', () => {
const result = GitHooksService.configureSkipList(['pre-commit', 'pre-push']);
expect(result.LEFTHOOK_EXCLUDE).toBe('pre-commit,pre-push');
});
it('should set HUSKY=0 when hooks are skipped', () => {
const result = GitHooksService.configureSkipList(['pre-commit']);
expect(result.HUSKY).toBe('0');
});
});
describe('disableHooks', () => {
it('should set core.hooksPath to an empty directory', async () => {
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
const { OrchestratorSystem } = require('../core/orchestrator-system');
await GitHooksService.disableHooks('/repo');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
expect.stringContaining('git -C "/repo" config core.hooksPath'),
true,
);
});
});
describe('installHooks', () => {
it('should run npx lefthook install when lefthook is detected', async () => {
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
});
const { OrchestratorSystem } = require('../core/orchestrator-system');
await GitHooksService.installHooks('/repo');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(`cd "/repo" && npx lefthook install`, true);
});
it('should log and return when no framework is detected', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const { OrchestratorSystem } = require('../core/orchestrator-system');
const OrchestratorLogger = require('../core/orchestrator-logger').default;
await GitHooksService.installHooks('/repo');
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
expect(OrchestratorLogger.log).toHaveBeenCalledWith(expect.stringContaining('No hook framework detected'));
});
});
});

View File

@@ -0,0 +1,96 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { OrchestratorSystem } from '../core/orchestrator-system';
import OrchestratorLogger from '../core/orchestrator-logger';
export class GitHooksService {
/**
* Detect which git hook framework is configured in the repository.
* Checks for lefthook and husky configuration files.
*/
static detectHookFramework(repoPath: string): 'lefthook' | 'husky' | 'none' {
// Check for lefthook config files
if (fs.existsSync(path.join(repoPath, 'lefthook.yml')) || fs.existsSync(path.join(repoPath, '.lefthook.yml'))) {
return 'lefthook';
}
// Check for husky directory
if (fs.existsSync(path.join(repoPath, '.husky'))) {
return 'husky';
}
return 'none';
}
/**
* Install git hooks using the detected framework.
* Errors are caught and logged as warnings - hook installation should not fail the build.
*/
static async installHooks(repoPath: string): Promise<void> {
const framework = GitHooksService.detectHookFramework(repoPath);
if (framework === 'none') {
OrchestratorLogger.log(`[GitHooks] No hook framework detected in ${repoPath}`);
return;
}
OrchestratorLogger.log(`[GitHooks] Detected hook framework: ${framework}`);
try {
if (framework === 'lefthook') {
await OrchestratorSystem.Run(`cd "${repoPath}" && npx lefthook install`, true);
OrchestratorLogger.log(`[GitHooks] Lefthook hooks installed`);
} else if (framework === 'husky') {
await OrchestratorSystem.Run(`cd "${repoPath}" && npx husky install`, true);
OrchestratorLogger.log(`[GitHooks] Husky hooks installed`);
}
} catch (error: any) {
OrchestratorLogger.logWarning(`[GitHooks] Hook installation failed: ${error.message}`);
}
}
/**
* Return environment variables that will skip the listed hooks.
* For lefthook: sets LEFTHOOK_EXCLUDE to a comma-separated list.
* For husky: sets HUSKY=0 to disable all hooks (husky does not support selective skipping).
* The caller is responsible for applying the returned env vars.
*/
static configureSkipList(skipList: string[]): Record<string, string> {
if (skipList.length === 0) {
return {};
}
// Return both lefthook and husky env vars so the caller can apply whichever is relevant.
// Lefthook supports selective hook exclusion.
const env: Record<string, string> = {
LEFTHOOK_EXCLUDE: skipList.join(','),
};
// Husky only supports full disable (HUSKY=0), not selective skipping.
// If any hooks are in the skip list, disable husky entirely.
env.HUSKY = '0';
OrchestratorLogger.log(`[GitHooks] Skip list configured: ${skipList.join(', ')}`);
return env;
}
/**
* Disable all git hooks by pointing core.hooksPath to an empty temporary directory.
* This prevents any hooks from running during the build.
*/
static async disableHooks(repoPath: string): Promise<void> {
try {
const emptyDir = path.join(os.tmpdir(), 'game-ci-empty-hooks');
fs.mkdirSync(emptyDir, { recursive: true });
await OrchestratorSystem.Run(`git -C "${repoPath}" config core.hooksPath "${emptyDir}"`, true);
OrchestratorLogger.log(`[GitHooks] Hooks disabled via core.hooksPath -> ${emptyDir}`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[GitHooks] Failed to disable hooks: ${error.message}`);
}
}
}

View File

@@ -0,0 +1,96 @@
import fs from 'node:fs';
import path from 'node:path';
import { LfsAgentService } from './lfs-agent-service';
// Mock dependencies
jest.mock('node:fs');
jest.mock('../core/orchestrator-system', () => ({
OrchestratorSystem: {
Run: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
error: jest.fn(),
},
}));
const mockFs = fs as jest.Mocked<typeof fs>;
describe('LfsAgentService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('configure', () => {
it('should call correct git config commands when agent exists', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const { OrchestratorSystem } = require('../core/orchestrator-system');
await LfsAgentService.configure(
'/usr/local/bin/elastic-git-storage',
'--verbose',
['/storage/path1', '/storage/path2'],
'/repo',
);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
`git -C "/repo" config lfs.customtransfer.elastic-git-storage.path "/usr/local/bin/elastic-git-storage"`,
);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
`git -C "/repo" config lfs.customtransfer.elastic-git-storage.args "--verbose"`,
);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
`git -C "/repo" config lfs.standalonetransferagent elastic-git-storage`,
);
});
it('should set LFS_STORAGE_PATHS environment variable when storagePaths provided', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
await LfsAgentService.configure('/usr/local/bin/elastic-git-storage', '', ['/path/a', '/path/b'], '/repo');
expect(process.env.LFS_STORAGE_PATHS).toBe('/path/a;/path/b');
});
it('should log warning and return early when agent executable does not exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const { OrchestratorSystem } = require('../core/orchestrator-system');
await LfsAgentService.configure('/nonexistent/agent', '', [], '/repo');
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
});
it('should derive agent name from executable filename', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const { OrchestratorSystem } = require('../core/orchestrator-system');
await LfsAgentService.configure('/tools/my-custom-agent.exe', '', [], '/repo');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
`git -C "/repo" config lfs.customtransfer.my-custom-agent.path "/tools/my-custom-agent.exe"`,
);
});
});
describe('validate', () => {
it('should return true when agent executable exists', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const result = await LfsAgentService.validate('/usr/local/bin/elastic-git-storage');
expect(result).toBe(true);
});
it('should return false when agent executable does not exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = await LfsAgentService.validate('/nonexistent/agent');
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,59 @@
import fs from 'node:fs';
import path from 'node:path';
import { OrchestratorSystem } from '../core/orchestrator-system';
import OrchestratorLogger from '../core/orchestrator-logger';
export class LfsAgentService {
/**
* Configure a custom LFS transfer agent in a git repository.
* Sets up the git config entries and environment variables needed for the agent.
*/
static async configure(
agentPath: string,
agentArgs: string,
storagePaths: string[],
repoPath: string,
): Promise<void> {
// Validate the agent executable exists
if (!fs.existsSync(agentPath)) {
OrchestratorLogger.logWarning(
`[LfsAgent] Agent executable not found at ${agentPath}, continuing without custom LFS agent`,
);
return;
}
// Derive agent name from executable filename (without extension)
const agentName = path.basename(agentPath, path.extname(agentPath));
OrchestratorLogger.log(`[LfsAgent] Configuring custom LFS transfer agent: ${agentName}`);
OrchestratorLogger.log(`[LfsAgent] Path: ${agentPath}`);
OrchestratorLogger.log(`[LfsAgent] Args: ${agentArgs}`);
// Set git config entries for the custom transfer agent
await OrchestratorSystem.Run(`git -C "${repoPath}" config lfs.customtransfer.${agentName}.path "${agentPath}"`);
await OrchestratorSystem.Run(`git -C "${repoPath}" config lfs.customtransfer.${agentName}.args "${agentArgs}"`);
await OrchestratorSystem.Run(`git -C "${repoPath}" config lfs.standalonetransferagent ${agentName}`);
// Set storage paths environment variable if provided
if (storagePaths.length > 0) {
const storagePathsValue = storagePaths.join(';');
process.env.LFS_STORAGE_PATHS = storagePathsValue;
OrchestratorLogger.log(`[LfsAgent] Storage paths: ${storagePathsValue}`);
}
OrchestratorLogger.log(`[LfsAgent] Custom LFS transfer agent configured successfully`);
}
/**
* Validate that the LFS transfer agent executable exists.
*/
static async validate(agentPath: string): Promise<boolean> {
const exists = fs.existsSync(agentPath);
if (!exists) {
OrchestratorLogger.logWarning(`[LfsAgent] Agent executable not found: ${agentPath}`);
}
return exists;
}
}

View File

@@ -0,0 +1,312 @@
import fs from 'node:fs';
import { SubmoduleProfileService } from './submodule-profile-service';
import { OrchestratorSystem } from '../core/orchestrator-system';
jest.mock('node:fs');
jest.mock('../core/orchestrator-system');
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
error: jest.fn(),
},
}));
const mockedFs = fs as jest.Mocked<typeof fs>;
const mockedSystem = OrchestratorSystem as jest.Mocked<typeof OrchestratorSystem>;
describe('SubmoduleProfileService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('parseProfile', () => {
it('reads and parses a valid YAML profile', () => {
const profileYaml = `
primary_submodule: Assets/_Game/Submodules/TurnOfWarEndlessCrusade
product_name: Endless Crusade
submodules:
- name: TurnOfWar
branch: main
- name: TurnOfWarEndlessCrusade
branch: main
- name: AreaOfOperations
branch: empty
`;
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockReturnValue(profileYaml);
const profile = SubmoduleProfileService.parseProfile('/path/to/profile.yml');
expect(profile.primary_submodule).toBe('Assets/_Game/Submodules/TurnOfWarEndlessCrusade');
expect(profile.product_name).toBe('Endless Crusade');
expect(profile.submodules).toHaveLength(3);
expect(profile.submodules[0]).toEqual({ name: 'TurnOfWar', branch: 'main' });
expect(profile.submodules[1]).toEqual({ name: 'TurnOfWarEndlessCrusade', branch: 'main' });
expect(profile.submodules[2]).toEqual({ name: 'AreaOfOperations', branch: 'empty' });
});
it('throws if profile file does not exist', () => {
mockedFs.existsSync.mockReturnValue(false);
expect(() => SubmoduleProfileService.parseProfile('/missing/profile.yml')).toThrow('Submodule profile not found');
});
it('throws if YAML is missing submodules array', () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockReturnValue('product_name: Test\n');
expect(() => SubmoduleProfileService.parseProfile('/path/to/bad.yml')).toThrow("expected 'submodules' array");
});
});
describe('mergeVariant', () => {
it('correctly overlays variant entries on base profile', () => {
const baseYaml = `
submodules:
- name: ModuleA
branch: main
- name: ModuleB
branch: main
`;
const variantYaml = `
product_name: Server Build
submodules:
- name: ModuleB
branch: empty
- name: ModuleC
branch: develop
`;
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockImplementation((filePath: any) => {
if (filePath === '/base.yml') return baseYaml;
if (filePath === '/variant.yml') return variantYaml;
return '';
});
const base = SubmoduleProfileService.parseProfile('/base.yml');
const merged = SubmoduleProfileService.mergeVariant(base, '/variant.yml');
expect(merged.product_name).toBe('Server Build');
expect(merged.submodules).toHaveLength(3);
const moduleA = merged.submodules.find((s) => s.name === 'ModuleA');
const moduleB = merged.submodules.find((s) => s.name === 'ModuleB');
const moduleC = merged.submodules.find((s) => s.name === 'ModuleC');
expect(moduleA?.branch).toBe('main');
expect(moduleB?.branch).toBe('empty');
expect(moduleC?.branch).toBe('develop');
});
});
describe('matchSubmodule', () => {
it('matches exact submodule name', () => {
expect(SubmoduleProfileService.matchSubmodule('TurnOfWar', 'TurnOfWar')).toBe(true);
});
it('matches exact leaf folder name against full path', () => {
expect(SubmoduleProfileService.matchSubmodule('Assets/_Game/Submodules/TurnOfWar', 'TurnOfWar')).toBe(true);
});
it('does not match unrelated names', () => {
expect(SubmoduleProfileService.matchSubmodule('TurnOfWar', 'AreaOfOperations')).toBe(false);
});
it('matches trailing wildcard against full path', () => {
expect(SubmoduleProfileService.matchSubmodule('Assets/_Engine/Submodules/PluginsFoo', 'Plugins*')).toBe(true);
});
it('matches trailing wildcard against full path prefix', () => {
expect(
SubmoduleProfileService.matchSubmodule(
'Assets/_Engine/Submodules/PluginsFoo',
'Assets/_Engine/Submodules/Plugins*',
),
).toBe(true);
});
it('does not match wildcard that does not align', () => {
expect(SubmoduleProfileService.matchSubmodule('Assets/_Engine/Submodules/SensorToolkit', 'Plugins*')).toBe(false);
});
});
describe('parseGitmodules', () => {
it('parses a typical .gitmodules file', () => {
const gitmodulesContent = `[submodule "Assets/_Game/Submodules/TurnOfWar"]
\tpath = Assets/_Game/Submodules/TurnOfWar
\turl = https://github.com/org/TurnOfWar.git
[submodule "Assets/_Game/Submodules/EndlessCrusade"]
\tpath = Assets/_Game/Submodules/EndlessCrusade
\turl = https://github.com/org/EndlessCrusade.git
[submodule "Assets/_Engine/Submodules/SensorToolkit"]
\tpath = Assets/_Engine/Submodules/SensorToolkit
\turl = https://github.com/org/SensorToolkit.git
`;
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockReturnValue(gitmodulesContent);
const result = SubmoduleProfileService.parseGitmodules('/repo');
expect(result.size).toBe(3);
expect(result.get('Assets/_Game/Submodules/TurnOfWar')).toBe('Assets/_Game/Submodules/TurnOfWar');
expect(result.get('Assets/_Game/Submodules/EndlessCrusade')).toBe('Assets/_Game/Submodules/EndlessCrusade');
expect(result.get('Assets/_Engine/Submodules/SensorToolkit')).toBe('Assets/_Engine/Submodules/SensorToolkit');
});
it('returns empty map when .gitmodules does not exist', () => {
mockedFs.existsSync.mockReturnValue(false);
const result = SubmoduleProfileService.parseGitmodules('/repo');
expect(result.size).toBe(0);
});
});
describe('createInitPlan', () => {
it('generates correct init and skip actions', async () => {
const profileYaml = `
submodules:
- name: TurnOfWar
branch: main
- name: EndlessCrusade
branch: main
- name: SensorToolkit
branch: empty
`;
const gitmodulesContent = `[submodule "Assets/_Game/Submodules/TurnOfWar"]
\tpath = Assets/_Game/Submodules/TurnOfWar
\turl = https://github.com/org/TurnOfWar.git
[submodule "Assets/_Game/Submodules/EndlessCrusade"]
\tpath = Assets/_Game/Submodules/EndlessCrusade
\turl = https://github.com/org/EndlessCrusade.git
[submodule "Assets/_Engine/Submodules/SensorToolkit"]
\tpath = Assets/_Engine/Submodules/SensorToolkit
\turl = https://github.com/org/SensorToolkit.git
[submodule "Assets/_Game/Submodules/Unmatched"]
\tpath = Assets/_Game/Submodules/Unmatched
\turl = https://github.com/org/Unmatched.git
`;
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockImplementation((filePath: any) => {
if (String(filePath).endsWith('profile.yml')) return profileYaml;
if (String(filePath).endsWith('.gitmodules')) return gitmodulesContent;
return '';
});
const plan = await SubmoduleProfileService.createInitPlan('/path/to/profile.yml', '', '/repo');
expect(plan).toHaveLength(4);
const turnOfWar = plan.find((a) => a.name === 'Assets/_Game/Submodules/TurnOfWar');
expect(turnOfWar?.action).toBe('init');
expect(turnOfWar?.branch).toBe('main');
const endlessCrusade = plan.find((a) => a.name === 'Assets/_Game/Submodules/EndlessCrusade');
expect(endlessCrusade?.action).toBe('init');
expect(endlessCrusade?.branch).toBe('main');
const sensorToolkit = plan.find((a) => a.name === 'Assets/_Engine/Submodules/SensorToolkit');
expect(sensorToolkit?.action).toBe('skip');
expect(sensorToolkit?.branch).toBe('empty');
const unmatched = plan.find((a) => a.name === 'Assets/_Game/Submodules/Unmatched');
expect(unmatched?.action).toBe('skip');
expect(unmatched?.branch).toBe('empty');
});
it('applies variant overlay when variantPath is provided', async () => {
const profileYaml = `
submodules:
- name: TurnOfWar
branch: main
- name: EndlessCrusade
branch: main
`;
const variantYaml = `
submodules:
- name: EndlessCrusade
branch: empty
`;
const gitmodulesContent = `[submodule "Assets/_Game/Submodules/TurnOfWar"]
\tpath = Assets/_Game/Submodules/TurnOfWar
\turl = https://github.com/org/TurnOfWar.git
[submodule "Assets/_Game/Submodules/EndlessCrusade"]
\tpath = Assets/_Game/Submodules/EndlessCrusade
\turl = https://github.com/org/EndlessCrusade.git
`;
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockImplementation((filePath: any) => {
const p = String(filePath);
if (p.endsWith('profile.yml')) return profileYaml;
if (p.endsWith('variant.yml')) return variantYaml;
if (p.endsWith('.gitmodules')) return gitmodulesContent;
return '';
});
const plan = await SubmoduleProfileService.createInitPlan(
'/path/to/profile.yml',
'/path/to/variant.yml',
'/repo',
);
expect(plan).toHaveLength(2);
const turnOfWar = plan.find((a) => a.name === 'Assets/_Game/Submodules/TurnOfWar');
expect(turnOfWar?.action).toBe('init');
const endlessCrusade = plan.find((a) => a.name === 'Assets/_Game/Submodules/EndlessCrusade');
expect(endlessCrusade?.action).toBe('skip');
});
});
describe('execute', () => {
it('runs init commands for init actions and deinit for skip actions', async () => {
mockedSystem.Run.mockResolvedValue('');
const plan = [
{ name: 'ModuleA', path: 'Assets/ModuleA', branch: 'main', action: 'init' as const },
{ name: 'ModuleB', path: 'Assets/ModuleB', branch: 'develop', action: 'init' as const },
{ name: 'ModuleC', path: 'Assets/ModuleC', branch: 'empty', action: 'skip' as const },
];
await SubmoduleProfileService.execute(plan, '/repo');
// ModuleA: init only (branch is main, no checkout needed)
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/ModuleA');
// ModuleB: init + checkout develop
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/ModuleB');
expect(mockedSystem.Run).toHaveBeenCalledWith('git -C Assets/ModuleB checkout develop');
// ModuleC: deinit
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule deinit -f Assets/ModuleC 2>/dev/null || true');
});
it('configures auth when token is provided', async () => {
mockedSystem.Run.mockResolvedValue('');
await SubmoduleProfileService.execute([], '/repo', 'my-secret-token');
expect(mockedSystem.Run).toHaveBeenCalledWith(
'git config url."https://my-secret-token@github.com/".insteadOf "https://github.com/"',
);
});
it('does not configure auth when no token is provided', async () => {
mockedSystem.Run.mockResolvedValue('');
await SubmoduleProfileService.execute([], '/repo');
expect(mockedSystem.Run).not.toHaveBeenCalledWith(expect.stringContaining('git config url'));
});
});
});

View File

@@ -0,0 +1,226 @@
import fs from 'node:fs';
import path from 'node:path';
import YAML from 'yaml';
import { SubmoduleProfile, SubmoduleEntry, SubmoduleInitAction, SubmoduleInitPlan } from './submodule-profile-types';
import { OrchestratorSystem } from '../core/orchestrator-system';
import OrchestratorLogger from '../core/orchestrator-logger';
export class SubmoduleProfileService {
/**
* Parse a submodule profile YAML file and return the typed profile.
*/
static parseProfile(profilePath: string): SubmoduleProfile {
if (!fs.existsSync(profilePath)) {
throw new Error(`Submodule profile not found: ${profilePath}`);
}
const raw = fs.readFileSync(profilePath, 'utf8');
let parsed: any;
try {
parsed = YAML.parse(raw);
} catch (error: any) {
throw new Error(`Failed to parse submodule profile YAML at ${profilePath}: ${error.message}`);
}
if (!parsed || !Array.isArray(parsed.submodules)) {
throw new Error(`Invalid submodule profile: expected 'submodules' array in ${profilePath}`);
}
return {
primary_submodule: parsed.primary_submodule,
product_name: parsed.product_name,
submodules: parsed.submodules.map((entry: any) => ({
name: String(entry.name),
branch: String(entry.branch),
})),
};
}
/**
* Merge a variant profile on top of a base profile.
* Variant submodule entries override base entries matched by name.
* New variant entries are appended.
* Scalar fields (primary_submodule, product_name) are replaced by variant values.
*/
static mergeVariant(base: SubmoduleProfile, variantPath: string): SubmoduleProfile {
if (!fs.existsSync(variantPath)) {
throw new Error(`Submodule variant not found: ${variantPath}`);
}
const variant = SubmoduleProfileService.parseProfile(variantPath);
// Start with a copy of base submodules
const mergedEntries = new Map<string, SubmoduleEntry>();
for (const entry of base.submodules) {
mergedEntries.set(entry.name, { ...entry });
}
// Overlay variant entries
for (const entry of variant.submodules) {
mergedEntries.set(entry.name, { ...entry });
}
return {
primary_submodule: variant.primary_submodule ?? base.primary_submodule,
product_name: variant.product_name ?? base.product_name,
submodules: [...mergedEntries.values()],
};
}
/**
* Parse the .gitmodules file from a repository and return a map of submodule name -> path.
*/
static parseGitmodules(repoPath: string): Map<string, string> {
const gitmodulesPath = path.join(repoPath, '.gitmodules');
const result = new Map<string, string>();
if (!fs.existsSync(gitmodulesPath)) {
return result;
}
const content = fs.readFileSync(gitmodulesPath, 'utf8');
const lines = content.split('\n');
let currentName: string | undefined;
for (const line of lines) {
const trimmed = line.trim();
// Match [submodule "name"]
const submoduleMatch = trimmed.match(/^\[submodule\s+"(.+)"\]$/);
if (submoduleMatch) {
currentName = submoduleMatch[1];
continue;
}
// Match path = value
const pathMatch = trimmed.match(/^path\s*=\s*(.+)$/);
if (pathMatch && currentName) {
result.set(currentName, pathMatch[1].trim());
}
}
return result;
}
/**
* Match a submodule name/path against a profile pattern.
* Supports exact match and glob-like patterns (only `*` wildcard at end).
* Matches against both the full submodule path and the leaf folder name.
*/
static matchSubmodule(submoduleName: string, pattern: string): boolean {
// Check for trailing wildcard
if (pattern.endsWith('*')) {
const prefix = pattern.slice(0, -1);
// Match against full path
if (submoduleName.startsWith(prefix)) {
return true;
}
// Match against leaf folder name
const leaf = submoduleName.split('/').pop() || '';
if (leaf.startsWith(prefix)) {
return true;
}
return false;
}
// Exact match against full path
if (submoduleName === pattern) {
return true;
}
// Exact match against leaf folder name
const leaf = submoduleName.split('/').pop() || '';
if (leaf === pattern) {
return true;
}
return false;
}
/**
* Create an initialization plan by matching .gitmodules entries against profile rules.
* Unmatched submodules default to 'skip'.
*/
static async createInitPlan(profilePath: string, variantPath: string, repoPath: string): Promise<SubmoduleInitPlan> {
let profile = SubmoduleProfileService.parseProfile(profilePath);
if (variantPath) {
profile = SubmoduleProfileService.mergeVariant(profile, variantPath);
}
const gitmodules = SubmoduleProfileService.parseGitmodules(repoPath);
const plan: SubmoduleInitPlan = [];
for (const [name, submodulePath] of gitmodules) {
let matchedEntry: SubmoduleEntry | undefined;
for (const entry of profile.submodules) {
if (
SubmoduleProfileService.matchSubmodule(name, entry.name) ||
SubmoduleProfileService.matchSubmodule(submodulePath, entry.name)
) {
matchedEntry = entry;
break;
}
}
if (matchedEntry) {
const action: SubmoduleInitAction = {
name,
path: submodulePath,
branch: matchedEntry.branch,
action: matchedEntry.branch === 'empty' ? 'skip' : 'init',
};
plan.push(action);
} else {
// Unmatched submodules default to skip
plan.push({
name,
path: submodulePath,
branch: 'empty',
action: 'skip',
});
}
}
return plan;
}
/**
* Execute a submodule initialization plan.
* Configures auth if token is provided, then inits or deinits each submodule.
*/
static async execute(plan: SubmoduleInitPlan, repoPath: string, token?: string): Promise<void> {
if (token) {
OrchestratorLogger.log('Configuring git authentication for submodule initialization...');
await OrchestratorSystem.Run(`git config url."https://${token}@github.com/".insteadOf "https://github.com/"`);
}
for (const action of plan) {
const fullPath = path.posix.join(repoPath, action.path).replace(/\\/g, '/');
if (action.action === 'init') {
OrchestratorLogger.log(`Initializing submodule: ${action.name} (branch: ${action.branch})`);
await OrchestratorSystem.Run(`git submodule update --init ${action.path}`);
if (action.branch !== 'main') {
OrchestratorLogger.log(`Checking out branch '${action.branch}' for submodule: ${action.name}`);
await OrchestratorSystem.Run(`git -C ${action.path} checkout ${action.branch}`);
}
} else {
OrchestratorLogger.log(`Skipping submodule: ${action.name}`);
await OrchestratorSystem.Run(`git submodule deinit -f ${action.path} 2>/dev/null || true`);
}
}
OrchestratorLogger.log(
`Submodule initialization complete: ${plan.filter((a) => a.action === 'init').length} initialized, ${
plan.filter((a) => a.action === 'skip').length
} skipped`,
);
}
}

View File

@@ -0,0 +1,19 @@
export interface SubmoduleEntry {
name: string;
branch: string;
}
export interface SubmoduleProfile {
primary_submodule?: string;
product_name?: string;
submodules: SubmoduleEntry[];
}
export interface SubmoduleInitAction {
name: string;
path: string;
branch: string;
action: 'init' | 'skip';
}
export type SubmoduleInitPlan = SubmoduleInitAction[];

View File

@@ -30,7 +30,7 @@ describe('Orchestrator Caching', () => {
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
containerHookFiles: `debug-cache`,
orchestratorBranch: `orchestrator-develop`,
orchestratorBranch: `main`,
orchestratorDebug: true,
};

View File

@@ -33,8 +33,7 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" /builder
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b orchestrator-develop "$REPO" /builder \
|| git clone -q -b main "$REPO" /builder \
git clone -q -b main "$REPO" /builder \
|| git clone -q "$REPO" /builder
fi
git clone -q -b ${Orchestrator.buildParameters.branch} ${OrchestratorFolders.targetBuildRepoUrl} /repo

View File

@@ -99,8 +99,7 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" "$DEST"
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b orchestrator-develop "$REPO" "$DEST" \
|| git clone -q -b main "$REPO" "$DEST" \
git clone -q -b main "$REPO" "$DEST" \
|| git clone -q "$REPO" "$DEST"
fi
chmod +x ${builderPath}`;