Merge origin/feature/orchestrator-enterprise-support into release/lts-infrastructure

This commit is contained in:
frostebite
2026-03-05 23:30:01 +00:00
32 changed files with 7084 additions and 598 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

File diff suppressed because it is too large Load Diff

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,114 @@ 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.'
childWorkspacesEnabled:
required: false
default: 'false'
description:
'Enable child workspace isolation for multi-product builds. Uses atomic filesystem moves for O(1) workspace
restore instead of tar/download/extract. Ideal for 50GB+ workspaces on self-hosted runners.'
childWorkspaceName:
required: false
default: ''
description:
'Name for this child workspace (e.g., product name like "TurnOfWar"). Used as the cache key for workspace
isolation. Required when childWorkspacesEnabled is true.'
childWorkspaceCacheRoot:
required: false
default: ''
description:
'Parent directory for cached child workspaces. Should be on the same NTFS volume as the build directory for O(1)
atomic restore via filesystem rename. Defaults to $RUNNER_TEMP/game-ci-workspaces.'
childWorkspacePreserveGit:
required: false
default: 'true'
description:
'Preserve .git directory in cached child workspace. Enables delta operations on restore but increases cache size.
Set to false to save disk space at the cost of full re-clone on restore.'
childWorkspaceSeparateLibrary:
required: false
default: 'true'
description:
'Cache Unity Library folder separately from the child workspace. Allows independent Library restore even when
workspace cache is invalidated. Recommended for large projects.'
lfsTransferAgent:
required: false
default: ''
description:
'Custom Git LFS transfer agent. Set to "elastic-git-storage" for built-in support (auto-installs from GitHub
releases). Append @version for a specific release (e.g. "elastic-git-storage@v1.0.0"). Or provide a path to any
custom transfer agent executable. 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'
gitHooksRunBeforeBuild:
required: false
default: ''
description:
'Comma-separated list of lefthook hook groups to run before the Unity build. Allows CI to trigger checks that
normally only run on git events. Example: pre-commit,pre-push. Requires lefthook. Works with Unity Git Hooks
(com.frostebite.unitygithooks) when installed as a UPM package — the init script runs automatically.'
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:

1648
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,97 @@ async function runMain() {
if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
// Child workspace isolation - restore cached workspace before any other setup
let childWorkspaceConfig: any;
if (buildParameters.childWorkspacesEnabled && buildParameters.childWorkspaceName) {
const { ChildWorkspaceService } = await import('./model/orchestrator/services/cache/child-workspace-service');
const cacheRoot =
buildParameters.childWorkspaceCacheRoot ||
path.join(buildParameters.runnerTempPath || process.env.RUNNER_TEMP || '', 'game-ci-workspaces');
childWorkspaceConfig = ChildWorkspaceService.buildConfig({
childWorkspacesEnabled: buildParameters.childWorkspacesEnabled,
childWorkspaceName: buildParameters.childWorkspaceName,
childWorkspaceCacheRoot: cacheRoot,
childWorkspacePreserveGit: buildParameters.childWorkspacePreserveGit,
childWorkspaceSeparateLibrary: buildParameters.childWorkspaceSeparateLibrary,
});
const projectFullPath = path.join(workspace, buildParameters.projectPath);
const restored = ChildWorkspaceService.initializeWorkspace(projectFullPath, childWorkspaceConfig);
core.info(
`Child workspace "${buildParameters.childWorkspaceName}": ${
restored ? 'restored from cache' : 'starting fresh'
}`,
);
// Log workspace size for resource tracking
const size = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
core.info(`Child workspace size after restore: ${size}`);
}
// 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 environment = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
Object.assign(process.env, environment);
}
} 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 +124,29 @@ 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);
}
}
// Child workspace isolation - save workspace for next run
if (childWorkspaceConfig && childWorkspaceConfig.enabled) {
const { ChildWorkspaceService } = await import('./model/orchestrator/services/cache/child-workspace-service');
const projectFullPath = path.join(workspace, buildParameters.projectPath);
const preSaveSize = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
core.info(`Child workspace size before save: ${preSaveSize}`);
ChildWorkspaceService.saveWorkspace(projectFullPath, childWorkspaceConfig);
core.info(`Child workspace "${buildParameters.childWorkspaceName}" saved to cache`);
}
} else {
await Orchestrator.run(buildParameters, baseImage.toString());
exitCode = 0;

View File

@@ -106,6 +106,25 @@ 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 childWorkspacesEnabled!: boolean;
public childWorkspaceName!: string;
public childWorkspaceCacheRoot!: string;
public childWorkspacePreserveGit!: boolean;
public childWorkspaceSeparateLibrary!: boolean;
public lfsTransferAgent!: string;
public lfsTransferAgentArgs!: string;
public lfsStoragePaths!: string;
public gitHooksEnabled!: boolean;
public gitHooksSkipList!: string;
public gitHooksRunBeforeBuild!: string;
public providerExecutable!: string;
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
@@ -242,6 +261,25 @@ 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,
childWorkspacesEnabled: Input.childWorkspacesEnabled,
childWorkspaceName: Input.childWorkspaceName,
childWorkspaceCacheRoot: Input.childWorkspaceCacheRoot,
childWorkspacePreserveGit: Input.childWorkspacePreserveGit,
childWorkspaceSeparateLibrary: Input.childWorkspaceSeparateLibrary,
lfsTransferAgent: Input.lfsTransferAgent,
lfsTransferAgentArgs: Input.lfsTransferAgentArgs,
lfsStoragePaths: Input.lfsStoragePaths,
gitHooksEnabled: Input.gitHooksEnabled,
gitHooksSkipList: Input.gitHooksSkipList,
gitHooksRunBeforeBuild: Input.gitHooksRunBeforeBuild,
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,82 @@ 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 childWorkspacesEnabled(): boolean {
return (Input.getInput('childWorkspacesEnabled') ?? 'false') === 'true';
}
static get childWorkspaceName(): string {
return Input.getInput('childWorkspaceName') ?? '';
}
static get childWorkspaceCacheRoot(): string {
return Input.getInput('childWorkspaceCacheRoot') ?? '';
}
static get childWorkspacePreserveGit(): boolean {
return (Input.getInput('childWorkspacePreserveGit') ?? 'true') === 'true';
}
static get childWorkspaceSeparateLibrary(): boolean {
return (Input.getInput('childWorkspaceSeparateLibrary') ?? 'true') === '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 gitHooksRunBeforeBuild(): string {
return Input.getInput('gitHooksRunBeforeBuild') ?? '';
}
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,532 @@
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 * as core from '@actions/core';
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');
});
it('rejects on spawn error', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/nonexistent/path', {} as any);
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
child.emit('error', new Error('ENOENT'));
await expect(promise).rejects.toThrow('failed to spawn executable');
});
});
describe('cleanupWorkflow', () => {
it('sends cleanup-workflow command and returns result', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.cleanupWorkflow({ editorVersion: '2022.3' } as any, 'main', []);
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: 'cleaned' }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('cleaned');
const parsed = JSON.parse(child.stdin.write.mock.calls[0][0]);
expect(parsed.command).toBe('cleanup-workflow');
expect(parsed.params.branchName).toBe('main');
});
});
describe('garbageCollect', () => {
it('sends garbage-collect command with correct params', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.garbageCollect('filter*', true, 30, false, true);
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: '3 items removed' }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('3 items removed');
const parsed = JSON.parse(child.stdin.write.mock.calls[0][0]);
expect(parsed.command).toBe('garbage-collect');
expect(parsed.params.filter).toBe('filter*');
expect(parsed.params.previewOnly).toBe(true);
expect(parsed.params.olderThan).toBe(30);
expect(parsed.params.baseDependencies).toBe(true);
});
it('returns empty string when response has no output', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.garbageCollect('', false, 7, false, false);
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('');
});
});
describe('listWorkflow', () => {
it('returns workflow list from response', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listWorkflow();
const workflows = [{ Name: 'wf-1' }];
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: workflows }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toEqual(workflows);
});
it('returns empty array when result is missing', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listWorkflow();
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toEqual([]);
});
});
describe('watchWorkflow', () => {
it('forwards streaming output and resolves on success', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.watchWorkflow();
child.stdout.emit('data', Buffer.from('watching...\nstatus: running\n'));
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: 'completed' }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('completed');
const parsed = JSON.parse(child.stdin.write.mock.calls[0][0]);
expect(parsed.command).toBe('watch-workflow');
});
it('rejects on watch-workflow failure', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.watchWorkflow();
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: false, error: 'lost connection' }) + '\n'));
child.emit('close', 1);
await expect(promise).rejects.toThrow('lost connection');
});
it('rejects on spawn error', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.watchWorkflow();
child.emit('error', new Error('ENOENT'));
await expect(promise).rejects.toThrow('failed to spawn executable');
});
it('resolves with collected output when exit code 0 and no JSON', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.watchWorkflow();
child.stdout.emit('data', Buffer.from('line A\nline B\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('line A\nline B');
});
});
describe('stderr forwarding', () => {
it('forwards stderr lines to logger during execute', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listResources();
child.stderr.emit('data', Buffer.from('warning: something\n'));
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: [] }) + '\n'));
child.emit('close', 0);
await promise;
// stderr content included in error message if process fails
// Here it succeeds, so we just verify no rejection
});
it('includes stderr in error message when process fails without JSON', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listResources();
child.stderr.emit('data', Buffer.from('fatal: segfault\n'));
child.emit('close', 1);
await expect(promise).rejects.toThrow('fatal: segfault');
});
});
describe('timeout handling', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('rejects and kills process when execute command times out', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listResources();
// Advance past the 300s default timeout
jest.advanceTimersByTime(301_000);
await expect(promise).rejects.toThrow('timed out');
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
});
it('rejects and kills process when runTaskInWorkflow times out', 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', [], []);
// Advance past the 2-hour timeout (7_200_000ms)
jest.advanceTimersByTime(7_200_001);
await expect(promise).rejects.toThrow('run-task timed out');
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('CLI provider timed out after 120 minutes'));
});
it('rejects and kills process when watchWorkflow times out', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.watchWorkflow();
// Advance past the 1-hour timeout (3_600_000ms)
jest.advanceTimersByTime(3_600_001);
await expect(promise).rejects.toThrow('watch-workflow timed out');
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('CLI provider timed out after 60 minutes'));
});
it('escalates to SIGKILL after grace period on runTaskInWorkflow timeout', 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', [], []);
// Trigger the timeout
jest.advanceTimersByTime(7_200_001);
await expect(promise).rejects.toThrow('timed out');
// SIGTERM was sent
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
// Advance past the 10s grace period — SIGKILL should fire
jest.advanceTimersByTime(10_001);
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
});
it('does not send SIGKILL if process exits before grace period', 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', [], []);
// Trigger the timeout
jest.advanceTimersByTime(7_200_001);
await expect(promise).rejects.toThrow('timed out');
// Process exits voluntarily after SIGTERM
child.emit('close', 143);
// Advance past the grace period — SIGKILL should NOT fire because process already exited
jest.advanceTimersByTime(10_001);
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
// SIGKILL should not have been called because the close event cleared the timer
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
});
it('clears timeout when runTaskInWorkflow completes normally', 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', [], []);
// Process completes before timeout
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: 'done' }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('done');
// Advance far past timeout — should NOT reject
jest.advanceTimersByTime(8_000_000);
expect(child.kill).not.toHaveBeenCalled();
});
});
describe('available providers list', () => {
it('includes cli in the available providers', () => {
const providers = ProviderLoader.getAvailableProviders();
expect(providers).toContain('cli');
});
});
});

View File

@@ -0,0 +1,406 @@
import { spawn, ChildProcess } from 'child_process';
import * as core from '@actions/core';
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
const RUN_TASK_TIMEOUT_MS = 7_200_000; // 2 hours
const WATCH_WORKFLOW_TIMEOUT_MS = 3_600_000; // 1 hour
const SIGKILL_GRACE_MS = 10_000; // 10 seconds grace period before SIGKILL
/**
* Gracefully kill a child process: SIGTERM first, then SIGKILL after a grace period.
*/
function gracefulKill(child: ChildProcess, graceMs: number = SIGKILL_GRACE_MS): void {
child.kill('SIGTERM');
const forceKillTimer = setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {
// Process may already be dead
}
}, graceMs);
// Clear the force-kill timer if the process exits on its own
child.on('close', () => {
clearTimeout(forceKillTimer);
});
}
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,
},
};
const timeoutMs = RUN_TASK_TIMEOUT_MS;
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 = '';
let timedOut = false;
// Set up timeout to prevent indefinite hangs
const timer = setTimeout(() => {
timedOut = true;
const minutes = Math.round(timeoutMs / 60_000);
const message = `CLI provider timed out after ${minutes} minutes. The external provider may be unresponsive.`;
core.error(message);
gracefulKill(child);
reject(new Error(`CliProvider run-task timed out after ${timeoutMs}ms`));
}, timeoutMs);
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) => {
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;
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: {},
};
const timeoutMs = WATCH_WORKFLOW_TIMEOUT_MS;
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[] = [];
let timedOut = false;
// Set up timeout to prevent indefinite hangs
const timer = setTimeout(() => {
timedOut = true;
const minutes = Math.round(timeoutMs / 60_000);
const message = `CLI provider timed out after ${minutes} minutes. The external provider may be unresponsive.`;
core.error(message);
gracefulKill(child);
reject(new Error(`CliProvider watch-workflow timed out after ${timeoutMs}ms`));
}, timeoutMs);
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) => {
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;
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.
* Timeout applies a graceful SIGTERM followed by SIGKILL after a grace period.
*/
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 with graceful kill
const timer = setTimeout(() => {
timedOut = true;
gracefulKill(child);
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,458 @@
import fs from 'node:fs';
import path from 'node:path';
import { ChildWorkspaceService, ChildWorkspaceConfig } from './child-workspace-service';
jest.mock('node:fs');
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>;
function createConfig(overrides: Partial<ChildWorkspaceConfig> = {}): ChildWorkspaceConfig {
return {
enabled: true,
workspaceName: 'TurnOfWar',
parentCacheRoot: '/cache/workspaces',
preserveGitDirectory: true,
separateLibraryCache: true,
...overrides,
};
}
describe('ChildWorkspaceService', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('initializeWorkspace', () => {
it('should return false when no cached workspace exists', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = ChildWorkspaceService.initializeWorkspace('/project', createConfig());
expect(result).toBe(false);
});
it('should return false when cached workspace is empty', () => {
(mockFs.existsSync as jest.Mock).mockImplementation(
(p: string) => String(p) === path.join('/cache/workspaces', 'TurnOfWar'),
);
(mockFs.readdirSync as jest.Mock).mockReturnValue([]);
const result = ChildWorkspaceService.initializeWorkspace('/project', createConfig());
expect(result).toBe(false);
expect(mockFs.rmSync).toHaveBeenCalledWith(path.join('/cache/workspaces', 'TurnOfWar'), {
recursive: true,
force: true,
});
});
it('should restore workspace via atomic move when cache exists', () => {
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === cachedPath) return true;
if (String(p) === '/project') return false;
if (String(p) === '/') return true;
return false;
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['Assets', '.git', 'Library']);
const config = createConfig({ separateLibraryCache: false });
const result = ChildWorkspaceService.initializeWorkspace('/project', config);
expect(result).toBe(true);
expect(mockFs.renameSync).toHaveBeenCalledWith(cachedPath, '/project');
});
it('should remove existing target path before restoring', () => {
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === cachedPath) return true;
if (String(p) === '/project') return true;
return false;
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['Assets']);
const config = createConfig({ separateLibraryCache: false });
const result = ChildWorkspaceService.initializeWorkspace('/project', config);
expect(result).toBe(true);
expect(mockFs.rmSync).toHaveBeenCalledWith('/project', { recursive: true, force: true });
});
it('should restore Library cache separately when configured', () => {
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
const libraryBackupPath = path.join('/cache/workspaces', 'TurnOfWar-Library');
const libraryDestination = path.join('/project', 'Library');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === cachedPath) return true;
if (String(p) === '/project') return false;
if (String(p) === libraryBackupPath) return true;
if (String(p) === libraryDestination) return false;
return true; // parent dirs
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['Assets', 'ProjectSettings']);
const result = ChildWorkspaceService.initializeWorkspace('/project', createConfig());
expect(result).toBe(true);
// Should have been called twice: once for workspace, once for Library
expect(mockFs.renameSync).toHaveBeenCalledTimes(2);
expect(mockFs.renameSync).toHaveBeenCalledWith(cachedPath, '/project');
expect(mockFs.renameSync).toHaveBeenCalledWith(libraryBackupPath, libraryDestination);
});
it('should return false and log warning on error', () => {
(mockFs.existsSync as jest.Mock).mockImplementation(() => {
throw new Error('Access denied');
});
const result = ChildWorkspaceService.initializeWorkspace('/project', createConfig());
expect(result).toBe(false);
});
});
describe('saveWorkspace', () => {
it('should skip save when project path does not exist', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
ChildWorkspaceService.saveWorkspace('/project', createConfig());
expect(mockFs.renameSync).not.toHaveBeenCalled();
});
it('should save workspace via atomic move', () => {
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === '/project') return true;
if (String(p) === path.join('/project', 'Library')) return false;
if (String(p) === '/cache/workspaces') return true;
if (String(p) === cachedPath) return false;
return false;
});
const config = createConfig({ separateLibraryCache: false });
ChildWorkspaceService.saveWorkspace('/project', config);
expect(mockFs.renameSync).toHaveBeenCalledWith('/project', cachedPath);
});
it('should remove .git directory when preserveGit is false', () => {
const gitDirectory = path.join('/project', '.git');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === '/project') return true;
if (String(p) === gitDirectory) return true;
if (String(p) === path.join('/project', 'Library')) return false;
if (String(p) === '/cache/workspaces') return true;
return false;
});
const config = createConfig({ preserveGitDirectory: false, separateLibraryCache: false });
ChildWorkspaceService.saveWorkspace('/project', config);
expect(mockFs.rmSync).toHaveBeenCalledWith(gitDirectory, { recursive: true, force: true });
});
it('should not remove .git directory when preserveGit is true', () => {
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === '/project') return true;
if (String(p) === path.join('/project', 'Library')) return false;
if (String(p) === '/cache/workspaces') return true;
return false;
});
const config = createConfig({ preserveGitDirectory: true, separateLibraryCache: false });
ChildWorkspaceService.saveWorkspace('/project', config);
// rmSync should not have been called with .git path
const rmSyncCalls = (mockFs.rmSync as jest.Mock).mock.calls;
const gitRmCalls = rmSyncCalls.filter((call: any[]) => String(call[0]).includes('.git'));
expect(gitRmCalls).toHaveLength(0);
});
it('should remove existing cached workspace before saving', () => {
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === '/project') return true;
if (String(p) === path.join('/project', 'Library')) return false;
if (String(p) === '/cache/workspaces') return true;
if (String(p) === cachedPath) return true;
return false;
});
const config = createConfig({ separateLibraryCache: false });
ChildWorkspaceService.saveWorkspace('/project', config);
expect(mockFs.rmSync).toHaveBeenCalledWith(cachedPath, { recursive: true, force: true });
expect(mockFs.renameSync).toHaveBeenCalledWith('/project', cachedPath);
});
it('should save Library separately when separateLibraryCache is true', () => {
const libraryPath = path.join('/project', 'Library');
const libraryBackupPath = path.join('/cache/workspaces', 'TurnOfWar-Library');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === '/project') return true;
if (String(p) === libraryPath) return true;
if (String(p) === libraryBackupPath) return false;
if (String(p) === '/cache/workspaces') return true;
return false;
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['ScriptAssemblies', 'ShaderCache']);
ChildWorkspaceService.saveWorkspace('/project', createConfig());
expect(mockFs.renameSync).toHaveBeenCalledWith(libraryPath, libraryBackupPath);
});
it('should handle save errors gracefully', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.renameSync as jest.Mock).mockImplementation(() => {
throw new Error('Cross-device link');
});
(mockFs.readdirSync as jest.Mock).mockReturnValue([]);
// Should not throw
ChildWorkspaceService.saveWorkspace('/project', createConfig({ separateLibraryCache: false }));
});
});
describe('restoreLibraryCache', () => {
it('should return false when no Library backup exists', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = ChildWorkspaceService.restoreLibraryCache('/project', createConfig());
expect(result).toBe(false);
});
it('should return false when Library backup is empty', () => {
const libraryBackup = path.join('/cache/workspaces', 'TurnOfWar-Library');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => String(p) === libraryBackup);
(mockFs.readdirSync as jest.Mock).mockReturnValue([]);
const result = ChildWorkspaceService.restoreLibraryCache('/project', createConfig());
expect(result).toBe(false);
expect(mockFs.rmSync).toHaveBeenCalledWith(libraryBackup, { recursive: true, force: true });
});
it('should restore Library via atomic move', () => {
const libraryBackup = path.join('/cache/workspaces', 'TurnOfWar-Library');
const libraryDestination = path.join('/project', 'Library');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === libraryBackup) return true;
if (String(p) === libraryDestination) return false;
return false;
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['ScriptAssemblies']);
const result = ChildWorkspaceService.restoreLibraryCache('/project', createConfig());
expect(result).toBe(true);
expect(mockFs.renameSync).toHaveBeenCalledWith(libraryBackup, libraryDestination);
});
it('should use custom libraryBackupPath when provided', () => {
const customBackup = '/custom/library/cache';
const libraryDestination = path.join('/project', 'Library');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === customBackup) return true;
if (String(p) === libraryDestination) return false;
return false;
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['ScriptAssemblies']);
const config = createConfig({ libraryBackupPath: customBackup });
const result = ChildWorkspaceService.restoreLibraryCache('/project', config);
expect(result).toBe(true);
expect(mockFs.renameSync).toHaveBeenCalledWith(customBackup, libraryDestination);
});
it('should remove existing Library directory before restore', () => {
const libraryBackup = path.join('/cache/workspaces', 'TurnOfWar-Library');
const libraryDestination = path.join('/project', 'Library');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === libraryBackup) return true;
if (String(p) === libraryDestination) return true;
return false;
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['ScriptAssemblies']);
ChildWorkspaceService.restoreLibraryCache('/project', createConfig());
expect(mockFs.rmSync).toHaveBeenCalledWith(libraryDestination, { recursive: true, force: true });
});
});
describe('getWorkspaceSize', () => {
it('should return "0 B" for non-existent directory', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = ChildWorkspaceService.getWorkspaceSize('/nonexistent');
expect(result).toBe('0 B');
});
it('should calculate and format directory size', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue([
{ name: 'file1.txt', isDirectory: () => false, isFile: () => true },
{ name: 'file2.bin', isDirectory: () => false, isFile: () => true },
]);
(mockFs.statSync as jest.Mock).mockReturnValue({ size: 1024 * 1024 }); // 1 MB each
const result = ChildWorkspaceService.getWorkspaceSize('/workspace');
expect(result).toBe('2.00 MB');
});
it('should return "unknown" when existsSync throws', () => {
(mockFs.existsSync as jest.Mock).mockImplementation(() => {
throw new Error('Permission denied');
});
const result = ChildWorkspaceService.getWorkspaceSize('/workspace');
expect(result).toBe('unknown');
});
it('should recurse into subdirectories', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
let callCount = 0;
(mockFs.readdirSync as jest.Mock).mockImplementation(() => {
callCount++;
if (callCount === 1) {
return [
{ name: 'subdir', isDirectory: () => true, isFile: () => false },
{ name: 'root.txt', isDirectory: () => false, isFile: () => true },
];
}
return [{ name: 'nested.txt', isDirectory: () => false, isFile: () => true }];
});
(mockFs.statSync as jest.Mock).mockReturnValue({ size: 512 });
const result = ChildWorkspaceService.getWorkspaceSize('/workspace');
expect(result).toBe('1.00 KB');
});
});
describe('cleanStaleWorkspaces', () => {
it('should skip when cache root does not exist', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
ChildWorkspaceService.cleanStaleWorkspaces('/nonexistent', 7);
expect(mockFs.readdirSync).not.toHaveBeenCalled();
});
it('should remove workspaces older than retention period', () => {
const now = Date.now();
const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000;
const oneDayAgo = now - 1 * 24 * 60 * 60 * 1000;
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockImplementation((directoryPath: string) => {
if (String(directoryPath) === '/cache') {
return ['old-workspace', 'recent-workspace'];
}
return [];
});
(mockFs.statSync as jest.Mock).mockImplementation((filePath: string) => ({
isDirectory: () => true,
mtimeMs: String(filePath).includes('old') ? tenDaysAgo : oneDayAgo,
size: 0,
}));
ChildWorkspaceService.cleanStaleWorkspaces('/cache', 7);
expect(mockFs.rmSync).toHaveBeenCalledTimes(1);
expect(mockFs.rmSync).toHaveBeenCalledWith(path.join('/cache', 'old-workspace'), {
recursive: true,
force: true,
});
});
it('should not remove workspaces newer than retention period', () => {
const oneDayAgo = Date.now() - 1 * 24 * 60 * 60 * 1000;
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['recent-workspace']);
(mockFs.statSync as jest.Mock).mockReturnValue({
isDirectory: () => true,
mtimeMs: oneDayAgo,
});
ChildWorkspaceService.cleanStaleWorkspaces('/cache', 7);
expect(mockFs.rmSync).not.toHaveBeenCalled();
});
it('should handle errors during cleanup gracefully', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['broken-workspace']);
(mockFs.statSync as jest.Mock).mockImplementation(() => {
throw new Error('Access denied');
});
// Should not throw
ChildWorkspaceService.cleanStaleWorkspaces('/cache', 7);
});
});
describe('buildConfig', () => {
it('should build config from build parameters', () => {
const config = ChildWorkspaceService.buildConfig({
childWorkspacesEnabled: true,
childWorkspaceName: 'Shell',
childWorkspaceCacheRoot: '/d/cache',
childWorkspacePreserveGit: false,
childWorkspaceSeparateLibrary: true,
});
expect(config).toEqual({
enabled: true,
workspaceName: 'Shell',
parentCacheRoot: '/d/cache',
preserveGitDirectory: false,
separateLibraryCache: true,
});
});
it('should build config with defaults from disabled state', () => {
const config = ChildWorkspaceService.buildConfig({
childWorkspacesEnabled: false,
childWorkspaceName: '',
childWorkspaceCacheRoot: '',
childWorkspacePreserveGit: true,
childWorkspaceSeparateLibrary: true,
});
expect(config.enabled).toBe(false);
});
});
});

View File

@@ -0,0 +1,373 @@
import fs from 'node:fs';
import path from 'node:path';
import OrchestratorLogger from '../core/orchestrator-logger';
export interface ChildWorkspaceConfig {
enabled: boolean;
/** Name for this child workspace, used as cache key (e.g., "TurnOfWar", "Shell") */
workspaceName: string;
/** Parent directory for cached child workspaces. Should be on same NTFS volume for O(1) restore. */
parentCacheRoot: string;
/** Keep .git directory in cached workspace for delta operations (default: true) */
preserveGitDirectory: boolean;
/** Cache Library/ independently from workspace (default: true) */
separateLibraryCache: boolean;
/** Override location for Library cache. Defaults to parentCacheRoot/<workspaceName>/Library-cache */
libraryBackupPath?: string;
}
/**
* Child workspace isolation service for enterprise-scale CI builds.
*
* Instead of building in the git checkout directory, this service:
* 1. Keeps the root workspace lean (no LFS files in the checkout dir)
* 2. Creates isolated child workspaces per product/build-target
* 3. Each child workspace gets its own submodule profile, LFS hydration, and Library folder
* 4. After build, the child workspace (.git preserved) is moved to a parent-level backup directory
* 5. On next CI run, the child workspace is restored via atomic filesystem move (O(1) on NTFS)
* 6. Library folders are cached separately for independent restore
*
* This is orders of magnitude faster than actions/cache for 50GB+ workspaces.
*/
export class ChildWorkspaceService {
/**
* Initialize child workspace by restoring from cache if available.
* Uses atomic filesystem move (rename) for O(1) restore on same volume.
*
* @param projectPath - Target path where the workspace should live during build
* @param config - Child workspace configuration
* @returns true if restored from cache, false if starting fresh
*/
static initializeWorkspace(projectPath: string, config: ChildWorkspaceConfig): boolean {
const cachedWorkspacePath = path.join(config.parentCacheRoot, config.workspaceName);
try {
if (!fs.existsSync(cachedWorkspacePath)) {
OrchestratorLogger.log(`[ChildWorkspace] No cached workspace found at ${cachedWorkspacePath}, starting fresh`);
return false;
}
// Verify the cached workspace has content
const entries = fs.readdirSync(cachedWorkspacePath);
if (entries.length === 0) {
OrchestratorLogger.log(`[ChildWorkspace] Cached workspace at ${cachedWorkspacePath} is empty, starting fresh`);
fs.rmSync(cachedWorkspacePath, { recursive: true, force: true });
return false;
}
// If the target path already exists, remove it to make way for the move
if (fs.existsSync(projectPath)) {
OrchestratorLogger.log(`[ChildWorkspace] Removing existing target path: ${projectPath}`);
fs.rmSync(projectPath, { recursive: true, force: true });
}
// Ensure parent directory of project path exists
const parentDirectory = path.dirname(projectPath);
if (!fs.existsSync(parentDirectory)) {
fs.mkdirSync(parentDirectory, { recursive: true });
}
// Atomic move (rename) - O(1) on NTFS when on same volume
OrchestratorLogger.log(`[ChildWorkspace] Restoring workspace: ${cachedWorkspacePath} -> ${projectPath}`);
fs.renameSync(cachedWorkspacePath, projectPath);
OrchestratorLogger.log(`[ChildWorkspace] Workspace restored via atomic move`);
// Restore Library cache separately if configured
if (config.separateLibraryCache) {
ChildWorkspaceService.restoreLibraryCache(projectPath, config);
}
return true;
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Workspace restore failed: ${error.message}. Starting fresh.`);
return false;
}
}
/**
* Save child workspace after build for reuse on next CI run.
* Moves the entire workspace to the cache directory via atomic filesystem move.
*
* @param projectPath - Path to the workspace to save
* @param config - Child workspace configuration
*/
static saveWorkspace(projectPath: string, config: ChildWorkspaceConfig): void {
const cachedWorkspacePath = path.join(config.parentCacheRoot, config.workspaceName);
try {
if (!fs.existsSync(projectPath)) {
OrchestratorLogger.log(`[ChildWorkspace] Project path ${projectPath} does not exist, skipping save`);
return;
}
// Remove .git directory if not preserving it (saves space but loses delta capability)
if (!config.preserveGitDirectory) {
const gitDirectory = path.join(projectPath, '.git');
if (fs.existsSync(gitDirectory)) {
OrchestratorLogger.log(`[ChildWorkspace] Removing .git directory (preserveGit=false)`);
fs.rmSync(gitDirectory, { recursive: true, force: true });
}
}
// If separateLibraryCache, move Library/ to its own backup path before saving workspace
if (config.separateLibraryCache) {
ChildWorkspaceService.saveLibraryCache(projectPath, config);
}
// Ensure parent cache root exists
if (!fs.existsSync(config.parentCacheRoot)) {
fs.mkdirSync(config.parentCacheRoot, { recursive: true });
}
// Remove any existing cached workspace to make room
if (fs.existsSync(cachedWorkspacePath)) {
OrchestratorLogger.log(`[ChildWorkspace] Removing previous cached workspace: ${cachedWorkspacePath}`);
fs.rmSync(cachedWorkspacePath, { recursive: true, force: true });
}
// Atomic move (rename) - O(1) on NTFS when on same volume
OrchestratorLogger.log(`[ChildWorkspace] Saving workspace: ${projectPath} -> ${cachedWorkspacePath}`);
fs.renameSync(projectPath, cachedWorkspacePath);
OrchestratorLogger.log(`[ChildWorkspace] Workspace saved via atomic move`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Workspace save failed: ${error.message}`);
}
}
/**
* Restore Library folder from separate cache location.
*
* @param projectPath - Path to the workspace where Library should be restored
* @param config - Child workspace configuration
* @returns true if Library was restored from cache
*/
static restoreLibraryCache(projectPath: string, config: ChildWorkspaceConfig): boolean {
const libraryBackup = ChildWorkspaceService.resolveLibraryBackupPath(config);
const libraryDestination = path.join(projectPath, 'Library');
try {
if (!fs.existsSync(libraryBackup)) {
OrchestratorLogger.log(`[ChildWorkspace] No Library cache found at ${libraryBackup}`);
return false;
}
const entries = fs.readdirSync(libraryBackup);
if (entries.length === 0) {
OrchestratorLogger.log(`[ChildWorkspace] Library cache at ${libraryBackup} is empty`);
fs.rmSync(libraryBackup, { recursive: true, force: true });
return false;
}
// Remove existing Library directory if present
if (fs.existsSync(libraryDestination)) {
fs.rmSync(libraryDestination, { recursive: true, force: true });
}
// Atomic move
OrchestratorLogger.log(`[ChildWorkspace] Restoring Library cache: ${libraryBackup} -> ${libraryDestination}`);
fs.renameSync(libraryBackup, libraryDestination);
OrchestratorLogger.log(`[ChildWorkspace] Library cache restored`);
return true;
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Library cache restore failed: ${error.message}`);
return false;
}
}
/**
* Save Library folder to a separate cache location for independent restore.
* Moves Library/ out of the workspace before workspace save.
*
* @param projectPath - Path to the workspace containing Library/
* @param config - Child workspace configuration
*/
private static saveLibraryCache(projectPath: string, config: ChildWorkspaceConfig): void {
const libraryPath = path.join(projectPath, 'Library');
const libraryBackup = ChildWorkspaceService.resolveLibraryBackupPath(config);
try {
if (!fs.existsSync(libraryPath)) {
OrchestratorLogger.log(`[ChildWorkspace] No Library folder to cache`);
return;
}
const entries = fs.readdirSync(libraryPath);
if (entries.length === 0) {
OrchestratorLogger.log(`[ChildWorkspace] Library folder is empty, skipping cache`);
return;
}
// Ensure parent of backup path exists
const backupParent = path.dirname(libraryBackup);
if (!fs.existsSync(backupParent)) {
fs.mkdirSync(backupParent, { recursive: true });
}
// Remove existing Library backup
if (fs.existsSync(libraryBackup)) {
fs.rmSync(libraryBackup, { recursive: true, force: true });
}
// Atomic move
OrchestratorLogger.log(`[ChildWorkspace] Caching Library: ${libraryPath} -> ${libraryBackup}`);
fs.renameSync(libraryPath, libraryBackup);
OrchestratorLogger.log(`[ChildWorkspace] Library cached separately`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Library cache save failed: ${error.message}`);
}
}
/**
* Calculate the total size of a directory in human-readable format.
*
* @param directoryPath - Path to the directory to measure
* @returns Human-readable size string (e.g., "1.23 GB", "456.78 MB")
*/
static getWorkspaceSize(directoryPath: string): string {
try {
if (!fs.existsSync(directoryPath)) {
return '0 B';
}
const totalBytes = ChildWorkspaceService.calculateDirectorySize(directoryPath);
return ChildWorkspaceService.formatBytes(totalBytes);
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Failed to calculate workspace size: ${error.message}`);
return 'unknown';
}
}
/**
* Clean stale child workspaces that haven't been used within the retention period.
*
* @param parentCacheRoot - Root directory containing cached workspaces
* @param retentionDays - Maximum age in days before a workspace is considered stale
*/
static cleanStaleWorkspaces(parentCacheRoot: string, retentionDays: number): void {
try {
if (!fs.existsSync(parentCacheRoot)) {
OrchestratorLogger.log(`[ChildWorkspace] Cache root ${parentCacheRoot} does not exist, nothing to clean`);
return;
}
const now = Date.now();
const maxAgeMs = retentionDays * 24 * 60 * 60 * 1000;
const entries = fs.readdirSync(parentCacheRoot);
let removedCount = 0;
let freedBytes = 0;
for (const entry of entries) {
const entryPath = path.join(parentCacheRoot, entry);
try {
const stat = fs.statSync(entryPath);
if (stat.isDirectory() && now - stat.mtimeMs > maxAgeMs) {
const size = ChildWorkspaceService.calculateDirectorySize(entryPath);
fs.rmSync(entryPath, { recursive: true, force: true });
removedCount++;
freedBytes += size;
OrchestratorLogger.log(
`[ChildWorkspace] Cleaned stale workspace: ${entry} (age: ${Math.floor(
(now - stat.mtimeMs) / (24 * 60 * 60 * 1000),
)} days)`,
);
}
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Failed to clean ${entryPath}: ${error.message}`);
}
}
OrchestratorLogger.log(
`[ChildWorkspace] Cleanup complete: ${removedCount} stale workspaces removed, ${ChildWorkspaceService.formatBytes(
freedBytes,
)} freed`,
);
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Stale workspace cleanup failed: ${error.message}`);
}
}
/**
* Build a ChildWorkspaceConfig from build parameters and action inputs.
*/
static buildConfig(parameters: {
childWorkspacesEnabled: boolean;
childWorkspaceName: string;
childWorkspaceCacheRoot: string;
childWorkspacePreserveGit: boolean;
childWorkspaceSeparateLibrary: boolean;
}): ChildWorkspaceConfig {
return {
enabled: parameters.childWorkspacesEnabled,
workspaceName: parameters.childWorkspaceName,
parentCacheRoot: parameters.childWorkspaceCacheRoot,
preserveGitDirectory: parameters.childWorkspacePreserveGit,
separateLibraryCache: parameters.childWorkspaceSeparateLibrary,
};
}
/**
* Resolve the Library backup path from config, using a default if not overridden.
*/
private static resolveLibraryBackupPath(config: ChildWorkspaceConfig): string {
if (config.libraryBackupPath) {
return config.libraryBackupPath;
}
return path.join(config.parentCacheRoot, `${config.workspaceName}-Library`);
}
/**
* Recursively calculate total size of a directory in bytes.
*/
private static calculateDirectorySize(directoryPath: string): number {
let totalSize = 0;
try {
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
totalSize += ChildWorkspaceService.calculateDirectorySize(fullPath);
} else if (entry.isFile()) {
totalSize += fs.statSync(fullPath).size;
}
}
} catch {
// Permission errors or race conditions — return what we have
}
return totalSize;
}
/**
* Format bytes into human-readable string.
*/
private static formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const index = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, index);
return `${value.toFixed(2)} ${units[index]}`;
}
}

View File

@@ -0,0 +1,272 @@
import fs from 'node:fs';
import path from 'node:path';
import { LocalCacheService } from './local-cache-service';
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-');
});
it('should handle dots in version string', () => {
const key = LocalCacheService.generateCacheKey('Android', '6000.0.23f1', 'main');
expect(key).toBe('Android-6000_0_23f1-main');
});
it('should preserve hyphens in platform names', () => {
const key = LocalCacheService.generateCacheKey('Standalone-Linux64', '2021.3.1f1', 'main');
expect(key).toBe('Standalone-Linux64-2021_3_1f1-main');
});
});
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);
});
it('should restore from the latest tar file on cache hit', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['lib-1000.tar', 'lib-2000.tar']);
(mockFs.statSync as jest.Mock).mockImplementation((filePath: string) => ({
mtimeMs: String(filePath).includes('lib-2000') ? 2000 : 1000,
}));
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
const { OrchestratorSystem } = require('../core/orchestrator-system');
const result = await LocalCacheService.restoreLibraryCache('/project', '/cache', 'key1');
expect(result).toBe(true);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('lib-2000.tar'), true);
});
it('should return false and log warning on error', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockImplementation(() => {
throw new Error('Permission denied');
});
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');
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
});
it('should skip save when Library folder is empty', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue([]);
await LocalCacheService.saveLibraryCache('/project', '/cache', 'key1');
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
});
it('should create cache directory and save tar', 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 });
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('tar -cf'), true);
});
});
describe('restoreLfsCache', () => {
it('should return false on cache miss', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = await LocalCacheService.restoreLfsCache('/repo', '/cache', 'key1');
expect(result).toBe(false);
});
it('should return false when no tar files exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['readme.txt']);
const result = await LocalCacheService.restoreLfsCache('/repo', '/cache', 'key1');
expect(result).toBe(false);
});
it('should restore from latest tar on hit', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['lfs-100.tar', 'lfs-200.tar']);
(mockFs.statSync as jest.Mock).mockImplementation((filePath: string) => ({
mtimeMs: String(filePath).includes('lfs-200') ? 200 : 100,
}));
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
const { OrchestratorSystem } = require('../core/orchestrator-system');
const result = await LocalCacheService.restoreLfsCache('/repo', '/cache', 'key1');
expect(result).toBe(true);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('lfs-200.tar'), true);
});
});
describe('saveLfsCache', () => {
it('should skip when .git/lfs does not exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
await LocalCacheService.saveLfsCache('/repo', '/cache', 'key1');
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
});
it('should skip when .git/lfs is empty', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue([]);
await LocalCacheService.saveLfsCache('/repo', '/cache', 'key1');
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
});
it('should create cache directory and save tar when lfs has content', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockImplementation((dirPath: string) => {
if (String(dirPath).includes('lfs') && !String(dirPath).includes('cache')) {
return ['objects', 'tmp'];
}
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.saveLfsCache('/repo', '/cache', 'key1');
expect(mockFs.mkdirSync).toHaveBeenCalledWith(path.join('/cache', 'key1', 'lfs'), { recursive: true });
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('tar -cf'), true);
});
it('should handle save errors gracefully', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockImplementation(() => {
throw new Error('Disk full');
});
// Should not throw
await LocalCacheService.saveLfsCache('/repo', '/cache', 'key1');
});
});
describe('garbageCollect', () => {
it('should skip when cache root does not exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
await LocalCacheService.garbageCollect('/nonexistent');
});
it('should remove directories older than maxAgeDays', async () => {
const now = Date.now();
const eightDaysAgo = now - 8 * 24 * 60 * 60 * 1000;
const oneDayAgo = now - 1 * 24 * 60 * 60 * 1000;
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['old-cache', 'recent-cache']);
(mockFs.statSync as jest.Mock).mockImplementation((filePath: string) => ({
isDirectory: () => true,
mtimeMs: String(filePath).includes('old') ? eightDaysAgo : oneDayAgo,
}));
(mockFs.rmSync as jest.Mock).mockReturnValue(undefined);
await LocalCacheService.garbageCollect('/cache', 7);
expect(mockFs.rmSync).toHaveBeenCalledTimes(1);
expect(mockFs.rmSync).toHaveBeenCalledWith(path.join('/cache', 'old-cache'), { recursive: true, force: true });
});
it('should not remove directories newer than maxAgeDays', async () => {
const oneDayAgo = Date.now() - 1 * 24 * 60 * 60 * 1000;
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['recent-cache']);
(mockFs.statSync as jest.Mock).mockReturnValue({
isDirectory: () => true,
mtimeMs: oneDayAgo,
});
await LocalCacheService.garbageCollect('/cache', 7);
expect(mockFs.rmSync).not.toHaveBeenCalled();
});
});
});

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,405 @@
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('detectUnityGitHooks', () => {
it('should return true when package is in manifest.json', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(
JSON.stringify({
dependencies: {
'com.frostebite.unitygithooks': 'https://github.com/frostebite/UnityGitHooks.git#1.0.5',
},
}),
);
expect(GitHooksService.detectUnityGitHooks('/repo')).toBe(true);
});
it('should return false when package is not in manifest.json', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(
JSON.stringify({
dependencies: {
'com.unity.textmeshpro': '3.0.6',
},
}),
);
expect(GitHooksService.detectUnityGitHooks('/repo')).toBe(false);
});
it('should return false when manifest.json does not exist', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
expect(GitHooksService.detectUnityGitHooks('/repo')).toBe(false);
});
it('should return false on read error', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockImplementation(() => {
throw new Error('Permission denied');
});
expect(GitHooksService.detectUnityGitHooks('/repo')).toBe(false);
});
});
describe('findUnityGitHooksPackagePath', () => {
it('should find versioned package directory', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue([
'com.unity.textmeshpro@3.0.6',
'com.frostebite.unitygithooks@1.0.5',
'com.unity.ugui@1.0.0',
]);
const result = GitHooksService.findUnityGitHooksPackagePath('/repo');
expect(result).toContain('com.frostebite.unitygithooks@1.0.5');
});
it('should return empty string when package not in cache', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['com.unity.textmeshpro@3.0.6']);
const result = GitHooksService.findUnityGitHooksPackagePath('/repo');
expect(result).toBe('');
});
it('should return empty string when PackageCache does not exist', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = GitHooksService.findUnityGitHooksPackagePath('/repo');
expect(result).toBe('');
});
});
describe('initUnityGitHooks', () => {
it('should run the init script when found', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['com.frostebite.unitygithooks@1.0.5']);
await GitHooksService.initUnityGitHooks('/repo');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('init-unity-lefthook.js'), true);
});
it('should skip when package not found in cache', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
await GitHooksService.initUnityGitHooks('/repo');
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
});
it('should warn when init script does not exist', async () => {
const OrchestratorLogger = require('../core/orchestrator-logger').default;
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
// PackageCache dir exists, but init script doesn't
return !String(p).includes('init-unity-lefthook');
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['com.frostebite.unitygithooks@1.0.5']);
await GitHooksService.initUnityGitHooks('/repo');
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining('init script not found'));
});
it('should log warning on init failure', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
const OrchestratorLogger = require('../core/orchestrator-logger').default;
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['com.frostebite.unitygithooks@1.0.5']);
OrchestratorSystem.Run.mockRejectedValue(new Error('node not found'));
await GitHooksService.initUnityGitHooks('/repo');
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining('init failed'));
});
});
describe('configureUnityGitHooksCIEnv', () => {
it('should disable background project mode', () => {
const env = GitHooksService.configureUnityGitHooksCIEnv();
expect(env.UNITY_GITHOOKS_BACKGROUND_PROJECT_ENABLED).toBe('false');
});
it('should set CI=true', () => {
const env = GitHooksService.configureUnityGitHooksCIEnv();
expect(env.CI).toBe('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 init Unity Git Hooks before installing lefthook when detected', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
const OrchestratorLogger = require('../core/orchestrator-logger').default;
const callOrder: string[] = [];
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(
`{"dependencies":{"com.frostebite.unitygithooks":"https://github.com/frostebite/UnityGitHooks.git"}}`,
);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['com.frostebite.unitygithooks@1.0.5']);
OrchestratorSystem.Run.mockImplementation((cmd: string) => {
if (cmd.includes('init-unity-lefthook')) {
callOrder.push('init');
} else if (cmd.includes('lefthook install')) {
callOrder.push('install');
}
return Promise.resolve('');
});
await GitHooksService.installHooks('/repo');
// Init should happen before install
expect(callOrder).toEqual(['init', 'install']);
expect(OrchestratorLogger.log).toHaveBeenCalledWith(expect.stringContaining('Unity Git Hooks (UPM) detected'));
});
it('should set CI env vars when Unity Git Hooks detected', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(`{"dependencies":{"com.frostebite.unitygithooks":"1.0.5"}}`);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['com.frostebite.unitygithooks@1.0.5']);
await GitHooksService.installHooks('/repo');
expect(process.env.UNITY_GITHOOKS_BACKGROUND_PROJECT_ENABLED).toBe('false');
});
it('should run npx husky install when husky is detected', async () => {
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).endsWith('.husky');
});
const { OrchestratorSystem } = require('../core/orchestrator-system');
await GitHooksService.installHooks('/repo');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(`cd "/repo" && npx husky 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'));
});
it('should log warning on installation failure', async () => {
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
});
const { OrchestratorSystem } = require('../core/orchestrator-system');
const OrchestratorLogger = require('../core/orchestrator-logger').default;
OrchestratorSystem.Run.mockRejectedValue(new Error('npx not found'));
await GitHooksService.installHooks('/repo');
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining('Hook installation failed'));
});
});
describe('runHookGroups', () => {
it('should run each group via lefthook run', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
});
OrchestratorSystem.Run.mockResolvedValue('');
const results = await GitHooksService.runHookGroups('/repo', ['pre-commit', 'pre-push']);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(`cd "/repo" && npx lefthook run pre-commit`, true);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(`cd "/repo" && npx lefthook run pre-push`, true);
expect(results['pre-commit']).toBe(true);
expect(results['pre-push']).toBe(true);
});
it('should return empty results for empty groups', async () => {
const results = await GitHooksService.runHookGroups('/repo', []);
expect(results).toEqual({});
});
it('should warn and return empty if not using lefthook', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const OrchestratorLogger = require('../core/orchestrator-logger').default;
const results = await GitHooksService.runHookGroups('/repo', ['pre-commit']);
expect(results).toEqual({});
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining('requires lefthook'));
});
it('should mark failed groups as false', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
});
OrchestratorSystem.Run.mockResolvedValueOnce('') // pre-commit passes
.mockRejectedValueOnce(new Error('tests failed')); // pre-push fails
const results = await GitHooksService.runHookGroups('/repo', ['pre-commit', 'pre-push']);
expect(results['pre-commit']).toBe(true);
expect(results['pre-push']).toBe(false);
});
it('should log each group result', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
const OrchestratorLogger = require('../core/orchestrator-logger').default;
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
});
OrchestratorSystem.Run.mockResolvedValueOnce('').mockRejectedValueOnce(new Error('check failed'));
await GitHooksService.runHookGroups('/repo', ['pre-commit', 'commit-msg']);
expect(OrchestratorLogger.log).toHaveBeenCalledWith(expect.stringContaining("'pre-commit' passed"));
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining("'commit-msg' failed"));
});
});
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');
});
it('should handle single hook in skip list', () => {
const result = GitHooksService.configureSkipList(['commit-msg']);
expect(result.LEFTHOOK_EXCLUDE).toBe('commit-msg');
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,
);
});
it('should log warning on failure to disable hooks', async () => {
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
const { OrchestratorSystem } = require('../core/orchestrator-system');
const OrchestratorLogger = require('../core/orchestrator-logger').default;
OrchestratorSystem.Run.mockRejectedValue(new Error('git config failed'));
await GitHooksService.disableHooks('/repo');
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining('Failed to disable hooks'));
});
});
describe('constants', () => {
it('should have correct package name', () => {
expect(GitHooksService.UNITY_GIT_HOOKS_PACKAGE).toBe('com.frostebite.unitygithooks');
});
});
});

View File

@@ -0,0 +1,235 @@
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 {
static readonly UNITY_GIT_HOOKS_PACKAGE = 'com.frostebite.unitygithooks';
/**
* 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';
}
/**
* Detect if Unity Git Hooks (com.frostebite.unitygithooks) is installed as a UPM package.
* Checks Packages/manifest.json for the package dependency.
*/
static detectUnityGitHooks(repoPath: string): boolean {
const manifestPath = path.join(repoPath, 'Packages', 'manifest.json');
if (!fs.existsSync(manifestPath)) {
return false;
}
try {
const content = fs.readFileSync(manifestPath, 'utf8');
return content.includes(GitHooksService.UNITY_GIT_HOOKS_PACKAGE);
} catch {
return false;
}
}
/**
* Find the Unity Git Hooks package directory in the Library/PackageCache.
* Returns the path to the package directory, or empty string if not found.
*/
static findUnityGitHooksPackagePath(repoPath: string): string {
const packageCacheDir = path.join(repoPath, 'Library', 'PackageCache');
if (!fs.existsSync(packageCacheDir)) {
return '';
}
try {
const entries = fs.readdirSync(packageCacheDir);
const match = entries.find((entry) => entry.startsWith(GitHooksService.UNITY_GIT_HOOKS_PACKAGE));
if (match) {
return path.join(packageCacheDir, match);
}
} catch {
// PackageCache not available
}
return '';
}
/**
* Initialize Unity Git Hooks by running its init script.
* This installs the required npm modules that the hooks depend on.
* Should be called before installHooks() when Unity Git Hooks is detected.
*/
static async initUnityGitHooks(repoPath: string): Promise<void> {
const packagePath = GitHooksService.findUnityGitHooksPackagePath(repoPath);
if (!packagePath) {
OrchestratorLogger.log(`[GitHooks] Unity Git Hooks package not found in Library/PackageCache, skipping init`);
return;
}
const initScript = path.join(packagePath, '~js', 'init-unity-lefthook.js');
if (!fs.existsSync(initScript)) {
OrchestratorLogger.logWarning(`[GitHooks] Unity Git Hooks init script not found at ${initScript}`);
return;
}
OrchestratorLogger.log(`[GitHooks] Initializing Unity Git Hooks from ${packagePath}`);
try {
await OrchestratorSystem.Run(`cd "${repoPath}" && node "${initScript}"`, true);
OrchestratorLogger.log(`[GitHooks] Unity Git Hooks initialized successfully`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[GitHooks] Unity Git Hooks init failed: ${error.message}`);
}
}
/**
* Configure CI-friendly environment variables for Unity Git Hooks.
* Disables background project mode (CI already has an isolated workspace)
* and sets other env vars appropriate for headless CI environments.
*/
static configureUnityGitHooksCIEnv(): Record<string, string> {
return {
UNITY_GITHOOKS_BACKGROUND_PROJECT_ENABLED: 'false',
CI: 'true',
};
}
/**
* Install git hooks using the detected framework.
* If Unity Git Hooks is detected, initializes it first.
* 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}`);
// If Unity Git Hooks is present, initialize it before installing hooks
if (framework === 'lefthook' && GitHooksService.detectUnityGitHooks(repoPath)) {
OrchestratorLogger.log(`[GitHooks] Unity Git Hooks (UPM) detected, running init`);
// Set CI-friendly env vars
const ciEnv = GitHooksService.configureUnityGitHooksCIEnv();
for (const [key, value] of Object.entries(ciEnv)) {
process.env[key] = value;
}
await GitHooksService.initUnityGitHooks(repoPath);
}
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}`);
}
}
/**
* Explicitly run specific lefthook hook groups before the build.
* This allows CI to trigger pre-commit, pre-push, or other checks
* that would normally only run on git events.
*
* @param repoPath - Path to the repository
* @param hookGroups - Lefthook group names to run (e.g., ['pre-commit', 'pre-push'])
* @returns Map of group name to success/failure
*/
static async runHookGroups(repoPath: string, hookGroups: string[]): Promise<Record<string, boolean>> {
const results: Record<string, boolean> = {};
if (hookGroups.length === 0) {
return results;
}
const framework = GitHooksService.detectHookFramework(repoPath);
if (framework !== 'lefthook') {
OrchestratorLogger.logWarning(`[GitHooks] runHookGroups requires lefthook, but detected: ${framework}`);
return results;
}
OrchestratorLogger.log(`[GitHooks] Running ${hookGroups.length} hook group(s): ${hookGroups.join(', ')}`);
for (const group of hookGroups) {
try {
await OrchestratorSystem.Run(`cd "${repoPath}" && npx lefthook run ${group}`, true);
OrchestratorLogger.log(`[GitHooks] Hook group '${group}' passed`);
results[group] = true;
} catch (error: any) {
OrchestratorLogger.logWarning(`[GitHooks] Hook group '${group}' failed: ${error.message}`);
results[group] = false;
}
}
return results;
}
/**
* 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,395 @@
import fs from 'node:fs';
import os from 'node:os';
import { ElasticGitStorageService } from './elastic-git-storage-service';
jest.mock('node:fs');
jest.mock('node:os');
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(),
},
}));
jest.mock('./lfs-agent-service', () => ({
LfsAgentService: {
configure: jest.fn().mockResolvedValue(undefined),
},
}));
const mockFs = fs as jest.Mocked<typeof fs>;
const mockOs = os as jest.Mocked<typeof os>;
describe('ElasticGitStorageService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('parseAgentValue', () => {
it('should parse name without version', () => {
const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage');
expect(result.name).toBe('elastic-git-storage');
expect(result.version).toBe('latest');
});
it('should parse name@version', () => {
const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage@v1.0.0');
expect(result.name).toBe('elastic-git-storage');
expect(result.version).toBe('v1.0.0');
});
it('should parse name@latest', () => {
const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage@latest');
expect(result.name).toBe('elastic-git-storage');
expect(result.version).toBe('latest');
});
it('should handle trailing @ as latest', () => {
const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage@');
expect(result.name).toBe('elastic-git-storage');
expect(result.version).toBe('latest');
});
it('should handle whitespace', () => {
const result = ElasticGitStorageService.parseAgentValue(' elastic-git-storage@v2.0.0 ');
expect(result.name).toBe('elastic-git-storage');
expect(result.version).toBe('v2.0.0');
});
});
describe('isElasticGitStorage', () => {
it('should match exact name', () => {
expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage')).toBe(true);
});
it('should match with .exe extension', () => {
expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage.exe')).toBe(true);
});
it('should match with @version suffix', () => {
expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage@v1.0.0')).toBe(true);
});
it('should match with @latest suffix', () => {
expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage@latest')).toBe(true);
});
it('should match forward-slash path', () => {
expect(ElasticGitStorageService.isElasticGitStorage('/usr/local/bin/elastic-git-storage')).toBe(true);
});
it('should match backslash path', () => {
expect(ElasticGitStorageService.isElasticGitStorage('C:\\tools\\elastic-git-storage')).toBe(true);
});
it('should match path with .exe', () => {
expect(ElasticGitStorageService.isElasticGitStorage('C:\\tools\\elastic-git-storage.exe')).toBe(true);
});
it('should be case-insensitive', () => {
expect(ElasticGitStorageService.isElasticGitStorage('Elastic-Git-Storage')).toBe(true);
});
it('should handle whitespace', () => {
expect(ElasticGitStorageService.isElasticGitStorage(' elastic-git-storage ')).toBe(true);
});
it('should not match other agent names', () => {
expect(ElasticGitStorageService.isElasticGitStorage('lfs-folderstore')).toBe(false);
});
it('should return false for empty string', () => {
expect(ElasticGitStorageService.isElasticGitStorage('')).toBe(false);
});
it('should not match partial names', () => {
expect(ElasticGitStorageService.isElasticGitStorage('my-elastic-git-storage-v2')).toBe(false);
});
});
describe('findInstalled', () => {
it('should find on PATH via which/where', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
OrchestratorSystem.Run.mockResolvedValue('/usr/local/bin/elastic-git-storage\n');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const result = await ElasticGitStorageService.findInstalled();
expect(result).toBe('/usr/local/bin/elastic-git-storage');
});
it('should use where on windows', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('win32');
OrchestratorSystem.Run.mockResolvedValue('C:\\tools\\elastic-git-storage.exe\n');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const result = await ElasticGitStorageService.findInstalled();
expect(result).toBe('C:\\tools\\elastic-git-storage.exe');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('where'), false, true);
});
it('should check common install locations when not on PATH', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.homedir.mockReturnValue('/home/runner');
OrchestratorSystem.Run.mockRejectedValue(new Error('not found'));
(mockFs.existsSync as jest.Mock)
.mockReturnValueOnce(false) // RUNNER_TOOL_CACHE
.mockReturnValueOnce(true); // /usr/local/bin
const result = await ElasticGitStorageService.findInstalled();
expect(result).toBe('/usr/local/bin/elastic-git-storage');
});
it('should return empty string when not found anywhere', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.homedir.mockReturnValue('/home/runner');
OrchestratorSystem.Run.mockRejectedValue(new Error('not found'));
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = await ElasticGitStorageService.findInstalled();
expect(result).toBe('');
});
it('should check windows-specific locations on win32', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('win32');
mockOs.homedir.mockReturnValue('C:\\Users\\runner');
OrchestratorSystem.Run.mockRejectedValue(new Error('not found'));
const originalEnv = { ...process.env };
process.env.LOCALAPPDATA = 'C:\\Users\\runner\\AppData\\Local';
process.env.RUNNER_TOOL_CACHE = '';
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
return p.includes('AppData');
});
const result = await ElasticGitStorageService.findInstalled();
expect(result).toContain('elastic-git-storage.exe');
process.env = originalEnv;
});
});
describe('install', () => {
it('should download correct binary for linux amd64', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.tmpdir.mockReturnValue('/tmp');
OrchestratorSystem.Run.mockResolvedValue('');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const result = await ElasticGitStorageService.install('latest');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('elastic-git-storage_linux_amd64'));
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('chmod +x'));
expect(result).toContain('elastic-git-storage');
});
it('should download correct binary for darwin arm64', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('darwin');
mockOs.arch.mockReturnValue('arm64');
mockOs.tmpdir.mockReturnValue('/tmp');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
await ElasticGitStorageService.install('v1.2.0');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('elastic-git-storage_darwin_arm64'));
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('v1.2.0'));
});
it('should download .exe for windows', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('win32');
mockOs.arch.mockReturnValue('x64');
mockOs.tmpdir.mockReturnValue('C:\\temp');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
await ElasticGitStorageService.install('latest');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
expect.stringContaining('elastic-git-storage_windows_amd64.exe'),
);
// Should NOT chmod on windows
expect(OrchestratorSystem.Run).not.toHaveBeenCalledWith(expect.stringContaining('chmod'));
});
it('should use RUNNER_TOOL_CACHE for install dir when available', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
OrchestratorSystem.Run.mockResolvedValue('');
const originalCache = process.env.RUNNER_TOOL_CACHE;
process.env.RUNNER_TOOL_CACHE = '/opt/hostedtoolcache';
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const result = await ElasticGitStorageService.install('latest');
expect(result).toContain('hostedtoolcache');
if (originalCache === undefined) {
delete process.env.RUNNER_TOOL_CACHE;
} else {
process.env.RUNNER_TOOL_CACHE = originalCache;
}
});
it('should use latest release URL when version is latest', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.tmpdir.mockReturnValue('/tmp');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
await ElasticGitStorageService.install('latest');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('/releases/latest/download/'));
});
it('should use tagged release URL when version is specified', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.tmpdir.mockReturnValue('/tmp');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
await ElasticGitStorageService.install('v2.0.0');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('/releases/download/v2.0.0/'));
});
it('should return empty string on download failure', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.tmpdir.mockReturnValue('/tmp');
OrchestratorSystem.Run.mockRejectedValue(new Error('curl failed'));
const result = await ElasticGitStorageService.install('latest');
expect(result).toBe('');
});
it('should return empty string if binary not found after download', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.tmpdir.mockReturnValue('/tmp');
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = await ElasticGitStorageService.install('latest');
expect(result).toBe('');
});
});
describe('ensureAndConfigure', () => {
it('should use existing installation if found', async () => {
const { LfsAgentService } = require('./lfs-agent-service');
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.homedir.mockReturnValue('/home/runner');
// findInstalled returns a result
OrchestratorSystem.Run.mockResolvedValue('/usr/local/bin/elastic-git-storage\n');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const result = await ElasticGitStorageService.ensureAndConfigure('latest', '--verbose', ['/mnt/lfs'], '/repo');
expect(result).toBe('/usr/local/bin/elastic-git-storage');
expect(LfsAgentService.configure).toHaveBeenCalledWith(
'/usr/local/bin/elastic-git-storage',
'--verbose',
['/mnt/lfs'],
'/repo',
);
});
it('should install when not found and configure', async () => {
const { LfsAgentService } = require('./lfs-agent-service');
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.homedir.mockReturnValue('/home/runner');
mockOs.tmpdir.mockReturnValue('/tmp');
// findInstalled finds nothing
OrchestratorSystem.Run.mockRejectedValueOnce(new Error('not found')) // which
.mockResolvedValueOnce('') // curl download
.mockResolvedValueOnce(''); // chmod
(mockFs.existsSync as jest.Mock)
.mockReturnValueOnce(false) // RUNNER_TOOL_CACHE
.mockReturnValueOnce(false) // /usr/local/bin
.mockReturnValueOnce(false) // ~/.local/bin
.mockReturnValueOnce(true); // after install
const result = await ElasticGitStorageService.ensureAndConfigure('v1.0.0', '', [], '/repo');
expect(result).toContain('elastic-git-storage');
expect(LfsAgentService.configure).toHaveBeenCalled();
});
it('should return empty string when install fails', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.homedir.mockReturnValue('/home/runner');
mockOs.tmpdir.mockReturnValue('/tmp');
// findInstalled finds nothing
OrchestratorSystem.Run.mockRejectedValue(new Error('not found'));
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = await ElasticGitStorageService.ensureAndConfigure('latest', '', [], '/repo');
expect(result).toBe('');
});
it('should use default version when empty string passed', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.homedir.mockReturnValue('/home/runner');
mockOs.tmpdir.mockReturnValue('/tmp');
// findInstalled finds nothing
OrchestratorSystem.Run.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce('')
.mockResolvedValueOnce('');
(mockFs.existsSync as jest.Mock)
.mockReturnValueOnce(false)
.mockReturnValueOnce(false)
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
await ElasticGitStorageService.ensureAndConfigure('', '', [], '/repo');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('/releases/latest/download/'));
});
});
describe('constants', () => {
it('should have correct repo owner', () => {
expect(ElasticGitStorageService.REPO_OWNER).toBe('frostebite');
});
it('should have correct repo name', () => {
expect(ElasticGitStorageService.REPO_NAME).toBe('elastic-git-storage');
});
it('should have correct agent name', () => {
expect(ElasticGitStorageService.AGENT_NAME).toBe('elastic-git-storage');
});
});
});

View File

@@ -0,0 +1,211 @@
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';
import { LfsAgentService } from './lfs-agent-service';
/**
* Built-in support for elastic-git-storage, a custom Git LFS transfer agent
* that supports multiple storage backends (local filesystem, WebDAV, rclone remotes).
*
* When `lfsTransferAgent` is set to 'elastic-git-storage', this service:
* 1. Checks if the agent is already installed on the system
* 2. If not, downloads it from the GitHub release page
* 3. Makes it executable
* 4. Delegates to LfsAgentService for git config setup
*
* @see https://github.com/frostebite/elastic-git-storage
*/
export class ElasticGitStorageService {
static readonly REPO_OWNER = 'frostebite';
static readonly REPO_NAME = 'elastic-git-storage';
static readonly AGENT_NAME = 'elastic-git-storage';
/**
* Parse an agent value that may include a version suffix.
* Supports formats like:
* - 'elastic-git-storage' → { name: 'elastic-git-storage', version: 'latest' }
* - 'elastic-git-storage@v1.0.0' → { name: 'elastic-git-storage', version: 'v1.0.0' }
* - 'elastic-git-storage@latest' → { name: 'elastic-git-storage', version: 'latest' }
*/
static parseAgentValue(agentValue: string): { name: string; version: string } {
const trimmed = agentValue.trim();
const atIndex = trimmed.indexOf('@');
if (atIndex > 0) {
return {
name: trimmed.substring(0, atIndex),
version: trimmed.substring(atIndex + 1) || 'latest',
};
}
return { name: trimmed, version: 'latest' };
}
/**
* Check if the given lfsTransferAgent value refers to elastic-git-storage.
* Matches the exact name (without path) or a path ending in the agent name.
* Also matches 'elastic-git-storage@version' format.
*/
static isElasticGitStorage(agentValue: string): boolean {
if (!agentValue) return false;
const { name } = ElasticGitStorageService.parseAgentValue(agentValue);
const normalized = name.trim().toLowerCase();
return (
normalized === 'elastic-git-storage' ||
normalized === 'elastic-git-storage.exe' ||
normalized.endsWith('/elastic-git-storage') ||
normalized.endsWith('\\elastic-git-storage') ||
normalized.endsWith('/elastic-git-storage.exe') ||
normalized.endsWith('\\elastic-git-storage.exe')
);
}
/**
* Resolve the full path to the elastic-git-storage executable.
*
* Search order:
* 1. The provided path (if it's a full path and exists)
* 2. $PATH lookup via `which` / `where`
* 3. Known install locations ($RUNNER_TOOL_CACHE, /usr/local/bin, ~/.local/bin)
*
* @returns Full path to the executable, or empty string if not found
*/
static async findInstalled(): Promise<string> {
// Check PATH
try {
const whichCmd = os.platform() === 'win32' ? 'where elastic-git-storage' : 'which elastic-git-storage';
const result = await OrchestratorSystem.Run(whichCmd, false, true);
const foundPath = result.trim().split('\n')[0].trim();
if (foundPath && fs.existsSync(foundPath)) {
return foundPath;
}
} catch {
// Not on PATH
}
// Check common install locations
const candidates = [
path.join(process.env.RUNNER_TOOL_CACHE || '', 'elastic-git-storage', 'elastic-git-storage'),
'/usr/local/bin/elastic-git-storage',
path.join(os.homedir(), '.local', 'bin', 'elastic-git-storage'),
];
if (os.platform() === 'win32') {
candidates.push(
path.join(process.env.RUNNER_TOOL_CACHE || '', 'elastic-git-storage', 'elastic-git-storage.exe'),
path.join(process.env.LOCALAPPDATA || '', 'elastic-git-storage', 'elastic-git-storage.exe'),
);
}
for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) {
return candidate;
}
}
return '';
}
/**
* Install elastic-git-storage from GitHub releases.
*
* @param version - Version to install (e.g., 'v1.0.0', 'latest')
* @returns Path to the installed executable
*/
static async install(version: string = 'latest'): Promise<string> {
const platform = os.platform();
const arch = os.arch();
const osName = platform === 'win32' ? 'windows' : platform === 'darwin' ? 'darwin' : 'linux';
const archName = arch === 'x64' ? 'amd64' : arch === 'arm64' ? 'arm64' : 'amd64';
const ext = platform === 'win32' ? '.exe' : '';
const installDir = process.env.RUNNER_TOOL_CACHE
? path.join(process.env.RUNNER_TOOL_CACHE, 'elastic-git-storage')
: path.join(os.tmpdir(), 'elastic-git-storage');
const binaryName = `elastic-git-storage${ext}`;
const installPath = path.join(installDir, binaryName);
OrchestratorLogger.log(`[ElasticGitStorage] Installing to ${installPath}`);
// Create install directory
fs.mkdirSync(installDir, { recursive: true });
// Build download URL
const releaseTag = version === 'latest' ? 'latest' : version;
const assetName = `elastic-git-storage_${osName}_${archName}${ext}`;
let downloadUrl: string;
if (releaseTag === 'latest') {
downloadUrl = `https://github.com/${ElasticGitStorageService.REPO_OWNER}/${ElasticGitStorageService.REPO_NAME}/releases/latest/download/${assetName}`;
} else {
downloadUrl = `https://github.com/${ElasticGitStorageService.REPO_OWNER}/${ElasticGitStorageService.REPO_NAME}/releases/download/${releaseTag}/${assetName}`;
}
OrchestratorLogger.log(`[ElasticGitStorage] Downloading from ${downloadUrl}`);
try {
await OrchestratorSystem.Run(`curl -fsSL -o "${installPath}" "${downloadUrl}"`);
if (platform !== 'win32') {
await OrchestratorSystem.Run(`chmod +x "${installPath}"`);
}
// Verify installation
if (!fs.existsSync(installPath)) {
throw new Error(`Binary not found after download at ${installPath}`);
}
OrchestratorLogger.log(`[ElasticGitStorage] Successfully installed ${releaseTag} to ${installPath}`);
return installPath;
} catch (error: any) {
OrchestratorLogger.logWarning(
`[ElasticGitStorage] Failed to install: ${error.message}. Continuing without elastic-git-storage.`,
);
return '';
}
}
/**
* Ensure elastic-git-storage is available (find or install) and configure it.
*
* @param version - Version to install if not found ('latest' or a tag like 'v1.0.0')
* @param agentArgs - Additional arguments to pass to the agent
* @param storagePaths - Storage paths for the agent
* @param repoPath - Path to the git repository
* @returns Path to the configured executable, or empty string if setup failed
*/
static async ensureAndConfigure(
version: string,
agentArgs: string,
storagePaths: string[],
repoPath: string,
): Promise<string> {
OrchestratorLogger.log(`[ElasticGitStorage] Setting up elastic-git-storage (version: ${version || 'latest'})`);
// Try to find existing installation
let agentPath = await ElasticGitStorageService.findInstalled();
if (agentPath) {
OrchestratorLogger.log(`[ElasticGitStorage] Found existing installation at ${agentPath}`);
} else {
OrchestratorLogger.log(`[ElasticGitStorage] Not found on system, installing...`);
agentPath = await ElasticGitStorageService.install(version || 'latest');
if (!agentPath) {
return '';
}
}
// Delegate to LfsAgentService for git config setup
await LfsAgentService.configure(agentPath, agentArgs, storagePaths, repoPath);
return agentPath;
}
}

View File

@@ -0,0 +1,122 @@
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('configure with empty storagePaths', () => {
it('should not set LFS_STORAGE_PATHS when storagePaths is empty', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const originalValue = process.env.LFS_STORAGE_PATHS;
delete process.env.LFS_STORAGE_PATHS;
await LfsAgentService.configure('/usr/local/bin/agent', '', [], '/repo');
expect(process.env.LFS_STORAGE_PATHS).toBeUndefined();
if (originalValue !== undefined) {
process.env.LFS_STORAGE_PATHS = originalValue;
}
});
});
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);
});
it('should log warning when agent does not exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const OrchestratorLogger = require('../core/orchestrator-logger').default;
await LfsAgentService.validate('/nonexistent/agent');
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining('Agent executable not found'));
});
});
});

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