feat(cache): add child workspace isolation for multi-product CI builds (#777)

Implement two-level workspace isolation pattern for enterprise-scale CI:
- Atomic O(1) workspace restore via filesystem move (no tar/download/extract)
- Separate Library caching for independent restore
- .git preservation for delta operations
- Stale workspace cleanup with configurable retention policies
- 5 new action inputs: childWorkspacesEnabled, childWorkspaceName,
  childWorkspaceCacheRoot, childWorkspacePreserveGit,
  childWorkspaceSeparateLibrary
- 28 unit tests covering all service methods

This enables enterprise CI where workspaces are 50GB+ and traditional
caching via actions/cache is impractical. On NTFS, workspace restore
is O(1) via atomic rename when source and destination are on the same volume.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
frostebite
2026-03-05 12:02:03 +00:00
parent 26903e96dd
commit 007852a800
8 changed files with 1423 additions and 13 deletions
+36 -8
View File
@@ -316,14 +316,43 @@ inputs:
required: false required: false
default: 'false' default: 'false'
description: 'Cache Git LFS objects for local builds. Only effective when localCacheEnabled is true.' 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: lfsTransferAgent:
required: false required: false
default: '' default: ''
description: description:
'Custom Git LFS transfer agent. Set to "elastic-git-storage" for built-in support (auto-installs 'Custom Git LFS transfer agent. Set to "elastic-git-storage" for built-in support (auto-installs from GitHub
from GitHub releases). Append @version for a specific release (e.g. "elastic-git-storage@v1.0.0"). releases). Append @version for a specific release (e.g. "elastic-git-storage@v1.0.0"). Or provide a path to any
Or provide a path to any custom transfer agent executable. When set, the agent is registered custom transfer agent executable. When set, the agent is registered via git config before LFS operations.'
via git config before LFS operations.'
lfsTransferAgentArgs: lfsTransferAgentArgs:
required: false required: false
default: '' default: ''
@@ -349,10 +378,9 @@ inputs:
required: false required: false
default: '' default: ''
description: description:
'Comma-separated list of lefthook hook groups to run before the Unity build. Allows CI to 'Comma-separated list of lefthook hook groups to run before the Unity build. Allows CI to trigger checks that
trigger checks that normally only run on git events. Example: pre-commit,pre-push. Requires normally only run on git events. Example: pre-commit,pre-push. Requires lefthook. Works with Unity Git Hooks
lefthook. Works with Unity Git Hooks (com.frostebite.unitygithooks) when installed as a UPM (com.frostebite.unitygithooks) when installed as a UPM package — the init script runs automatically.'
package — the init script runs automatically.'
providerExecutable: providerExecutable:
required: false required: false
default: '' default: ''
Generated Vendored
+485 -2
View File
@@ -53,6 +53,26 @@ async function runMain() {
let exitCode = -1; let exitCode = -1;
if (buildParameters.providerStrategy === 'local') { if (buildParameters.providerStrategy === 'local') {
core.info('Building locally'); core.info('Building locally');
// Child workspace isolation - restore cached workspace before any other setup
let childWorkspaceConfig;
if (buildParameters.childWorkspacesEnabled && buildParameters.childWorkspaceName) {
const { ChildWorkspaceService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(93834)));
const cacheRoot = buildParameters.childWorkspaceCacheRoot ||
node_path_1.default.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 = node_path_1.default.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 // Submodule profile initialization
if (buildParameters.submoduleProfilePath) { if (buildParameters.submoduleProfilePath) {
const { SubmoduleProfileService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(88664))); const { SubmoduleProfileService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(88664)));
@@ -86,8 +106,8 @@ async function runMain() {
const { GitHooksService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(9146))); const { GitHooksService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(9146)));
await GitHooksService.installHooks(workspace); await GitHooksService.installHooks(workspace);
if (buildParameters.gitHooksSkipList) { if (buildParameters.gitHooksSkipList) {
const env = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(',')); const environment = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
Object.assign(process.env, env); Object.assign(process.env, environment);
} }
} }
else { else {
@@ -114,6 +134,15 @@ async function runMain() {
await LocalCacheService.saveLfsCache(workspace, cacheRoot, cacheKey); await LocalCacheService.saveLfsCache(workspace, cacheRoot, cacheKey);
} }
} }
// Child workspace isolation - save workspace for next run
if (childWorkspaceConfig && childWorkspaceConfig.enabled) {
const { ChildWorkspaceService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(93834)));
const projectFullPath = node_path_1.default.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 { else {
await model_1.Orchestrator.run(buildParameters, baseImage.toString()); await model_1.Orchestrator.run(buildParameters, baseImage.toString());
@@ -435,11 +464,17 @@ class BuildParameters {
localCacheRoot: input_1.default.localCacheRoot, localCacheRoot: input_1.default.localCacheRoot,
localCacheLibrary: input_1.default.localCacheLibrary, localCacheLibrary: input_1.default.localCacheLibrary,
localCacheLfs: input_1.default.localCacheLfs, localCacheLfs: input_1.default.localCacheLfs,
childWorkspacesEnabled: input_1.default.childWorkspacesEnabled,
childWorkspaceName: input_1.default.childWorkspaceName,
childWorkspaceCacheRoot: input_1.default.childWorkspaceCacheRoot,
childWorkspacePreserveGit: input_1.default.childWorkspacePreserveGit,
childWorkspaceSeparateLibrary: input_1.default.childWorkspaceSeparateLibrary,
lfsTransferAgent: input_1.default.lfsTransferAgent, lfsTransferAgent: input_1.default.lfsTransferAgent,
lfsTransferAgentArgs: input_1.default.lfsTransferAgentArgs, lfsTransferAgentArgs: input_1.default.lfsTransferAgentArgs,
lfsStoragePaths: input_1.default.lfsStoragePaths, lfsStoragePaths: input_1.default.lfsStoragePaths,
gitHooksEnabled: input_1.default.gitHooksEnabled, gitHooksEnabled: input_1.default.gitHooksEnabled,
gitHooksSkipList: input_1.default.gitHooksSkipList, gitHooksSkipList: input_1.default.gitHooksSkipList,
gitHooksRunBeforeBuild: input_1.default.gitHooksRunBeforeBuild,
providerExecutable: input_1.default.providerExecutable, providerExecutable: input_1.default.providerExecutable,
}; };
} }
@@ -1944,6 +1979,21 @@ class Input {
static get localCacheLfs() { static get localCacheLfs() {
return (Input.getInput('localCacheLfs') ?? 'false') === 'true'; return (Input.getInput('localCacheLfs') ?? 'false') === 'true';
} }
static get childWorkspacesEnabled() {
return (Input.getInput('childWorkspacesEnabled') ?? 'false') === 'true';
}
static get childWorkspaceName() {
return Input.getInput('childWorkspaceName') ?? '';
}
static get childWorkspaceCacheRoot() {
return Input.getInput('childWorkspaceCacheRoot') ?? '';
}
static get childWorkspacePreserveGit() {
return (Input.getInput('childWorkspacePreserveGit') ?? 'true') === 'true';
}
static get childWorkspaceSeparateLibrary() {
return (Input.getInput('childWorkspaceSeparateLibrary') ?? 'true') === 'true';
}
static get lfsTransferAgent() { static get lfsTransferAgent() {
return Input.getInput('lfsTransferAgent') ?? ''; return Input.getInput('lfsTransferAgent') ?? '';
} }
@@ -1959,6 +2009,9 @@ class Input {
static get gitHooksSkipList() { static get gitHooksSkipList() {
return Input.getInput('gitHooksSkipList') ?? ''; return Input.getInput('gitHooksSkipList') ?? '';
} }
static get gitHooksRunBeforeBuild() {
return Input.getInput('gitHooksRunBeforeBuild') ?? '';
}
static get providerExecutable() { static get providerExecutable() {
return Input.getInput('providerExecutable') ?? ''; return Input.getInput('providerExecutable') ?? '';
} }
@@ -8782,6 +8835,317 @@ exports.RemoteClientLogger = RemoteClientLogger;
RemoteClientLogger.value = ''; RemoteClientLogger.value = '';
/***/ }),
/***/ 93834:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.ChildWorkspaceService = void 0;
const node_fs_1 = __importDefault(__nccwpck_require__(87561));
const node_path_1 = __importDefault(__nccwpck_require__(49411));
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
/**
* 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.
*/
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, config) {
const cachedWorkspacePath = node_path_1.default.join(config.parentCacheRoot, config.workspaceName);
try {
if (!node_fs_1.default.existsSync(cachedWorkspacePath)) {
orchestrator_logger_1.default.log(`[ChildWorkspace] No cached workspace found at ${cachedWorkspacePath}, starting fresh`);
return false;
}
// Verify the cached workspace has content
const entries = node_fs_1.default.readdirSync(cachedWorkspacePath);
if (entries.length === 0) {
orchestrator_logger_1.default.log(`[ChildWorkspace] Cached workspace at ${cachedWorkspacePath} is empty, starting fresh`);
node_fs_1.default.rmSync(cachedWorkspacePath, { recursive: true, force: true });
return false;
}
// If the target path already exists, remove it to make way for the move
if (node_fs_1.default.existsSync(projectPath)) {
orchestrator_logger_1.default.log(`[ChildWorkspace] Removing existing target path: ${projectPath}`);
node_fs_1.default.rmSync(projectPath, { recursive: true, force: true });
}
// Ensure parent directory of project path exists
const parentDirectory = node_path_1.default.dirname(projectPath);
if (!node_fs_1.default.existsSync(parentDirectory)) {
node_fs_1.default.mkdirSync(parentDirectory, { recursive: true });
}
// Atomic move (rename) - O(1) on NTFS when on same volume
orchestrator_logger_1.default.log(`[ChildWorkspace] Restoring workspace: ${cachedWorkspacePath} -> ${projectPath}`);
node_fs_1.default.renameSync(cachedWorkspacePath, projectPath);
orchestrator_logger_1.default.log(`[ChildWorkspace] Workspace restored via atomic move`);
// Restore Library cache separately if configured
if (config.separateLibraryCache) {
ChildWorkspaceService.restoreLibraryCache(projectPath, config);
}
return true;
}
catch (error) {
orchestrator_logger_1.default.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, config) {
const cachedWorkspacePath = node_path_1.default.join(config.parentCacheRoot, config.workspaceName);
try {
if (!node_fs_1.default.existsSync(projectPath)) {
orchestrator_logger_1.default.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 = node_path_1.default.join(projectPath, '.git');
if (node_fs_1.default.existsSync(gitDirectory)) {
orchestrator_logger_1.default.log(`[ChildWorkspace] Removing .git directory (preserveGit=false)`);
node_fs_1.default.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 (!node_fs_1.default.existsSync(config.parentCacheRoot)) {
node_fs_1.default.mkdirSync(config.parentCacheRoot, { recursive: true });
}
// Remove any existing cached workspace to make room
if (node_fs_1.default.existsSync(cachedWorkspacePath)) {
orchestrator_logger_1.default.log(`[ChildWorkspace] Removing previous cached workspace: ${cachedWorkspacePath}`);
node_fs_1.default.rmSync(cachedWorkspacePath, { recursive: true, force: true });
}
// Atomic move (rename) - O(1) on NTFS when on same volume
orchestrator_logger_1.default.log(`[ChildWorkspace] Saving workspace: ${projectPath} -> ${cachedWorkspacePath}`);
node_fs_1.default.renameSync(projectPath, cachedWorkspacePath);
orchestrator_logger_1.default.log(`[ChildWorkspace] Workspace saved via atomic move`);
}
catch (error) {
orchestrator_logger_1.default.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, config) {
const libraryBackup = ChildWorkspaceService.resolveLibraryBackupPath(config);
const libraryDestination = node_path_1.default.join(projectPath, 'Library');
try {
if (!node_fs_1.default.existsSync(libraryBackup)) {
orchestrator_logger_1.default.log(`[ChildWorkspace] No Library cache found at ${libraryBackup}`);
return false;
}
const entries = node_fs_1.default.readdirSync(libraryBackup);
if (entries.length === 0) {
orchestrator_logger_1.default.log(`[ChildWorkspace] Library cache at ${libraryBackup} is empty`);
node_fs_1.default.rmSync(libraryBackup, { recursive: true, force: true });
return false;
}
// Remove existing Library directory if present
if (node_fs_1.default.existsSync(libraryDestination)) {
node_fs_1.default.rmSync(libraryDestination, { recursive: true, force: true });
}
// Atomic move
orchestrator_logger_1.default.log(`[ChildWorkspace] Restoring Library cache: ${libraryBackup} -> ${libraryDestination}`);
node_fs_1.default.renameSync(libraryBackup, libraryDestination);
orchestrator_logger_1.default.log(`[ChildWorkspace] Library cache restored`);
return true;
}
catch (error) {
orchestrator_logger_1.default.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
*/
static saveLibraryCache(projectPath, config) {
const libraryPath = node_path_1.default.join(projectPath, 'Library');
const libraryBackup = ChildWorkspaceService.resolveLibraryBackupPath(config);
try {
if (!node_fs_1.default.existsSync(libraryPath)) {
orchestrator_logger_1.default.log(`[ChildWorkspace] No Library folder to cache`);
return;
}
const entries = node_fs_1.default.readdirSync(libraryPath);
if (entries.length === 0) {
orchestrator_logger_1.default.log(`[ChildWorkspace] Library folder is empty, skipping cache`);
return;
}
// Ensure parent of backup path exists
const backupParent = node_path_1.default.dirname(libraryBackup);
if (!node_fs_1.default.existsSync(backupParent)) {
node_fs_1.default.mkdirSync(backupParent, { recursive: true });
}
// Remove existing Library backup
if (node_fs_1.default.existsSync(libraryBackup)) {
node_fs_1.default.rmSync(libraryBackup, { recursive: true, force: true });
}
// Atomic move
orchestrator_logger_1.default.log(`[ChildWorkspace] Caching Library: ${libraryPath} -> ${libraryBackup}`);
node_fs_1.default.renameSync(libraryPath, libraryBackup);
orchestrator_logger_1.default.log(`[ChildWorkspace] Library cached separately`);
}
catch (error) {
orchestrator_logger_1.default.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) {
try {
if (!node_fs_1.default.existsSync(directoryPath)) {
return '0 B';
}
const totalBytes = ChildWorkspaceService.calculateDirectorySize(directoryPath);
return ChildWorkspaceService.formatBytes(totalBytes);
}
catch (error) {
orchestrator_logger_1.default.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, retentionDays) {
try {
if (!node_fs_1.default.existsSync(parentCacheRoot)) {
orchestrator_logger_1.default.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 = node_fs_1.default.readdirSync(parentCacheRoot);
let removedCount = 0;
let freedBytes = 0;
for (const entry of entries) {
const entryPath = node_path_1.default.join(parentCacheRoot, entry);
try {
const stat = node_fs_1.default.statSync(entryPath);
if (stat.isDirectory() && now - stat.mtimeMs > maxAgeMs) {
const size = ChildWorkspaceService.calculateDirectorySize(entryPath);
node_fs_1.default.rmSync(entryPath, { recursive: true, force: true });
removedCount++;
freedBytes += size;
orchestrator_logger_1.default.log(`[ChildWorkspace] Cleaned stale workspace: ${entry} (age: ${Math.floor((now - stat.mtimeMs) / (24 * 60 * 60 * 1000))} days)`);
}
}
catch (error) {
orchestrator_logger_1.default.logWarning(`[ChildWorkspace] Failed to clean ${entryPath}: ${error.message}`);
}
}
orchestrator_logger_1.default.log(`[ChildWorkspace] Cleanup complete: ${removedCount} stale workspaces removed, ${ChildWorkspaceService.formatBytes(freedBytes)} freed`);
}
catch (error) {
orchestrator_logger_1.default.logWarning(`[ChildWorkspace] Stale workspace cleanup failed: ${error.message}`);
}
}
/**
* Build a ChildWorkspaceConfig from build parameters and action inputs.
*/
static buildConfig(parameters) {
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.
*/
static resolveLibraryBackupPath(config) {
if (config.libraryBackupPath) {
return config.libraryBackupPath;
}
return node_path_1.default.join(config.parentCacheRoot, `${config.workspaceName}-Library`);
}
/**
* Recursively calculate total size of a directory in bytes.
*/
static calculateDirectorySize(directoryPath) {
let totalSize = 0;
try {
const entries = node_fs_1.default.readdirSync(directoryPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = node_path_1.default.join(directoryPath, entry.name);
if (entry.isDirectory()) {
totalSize += ChildWorkspaceService.calculateDirectorySize(fullPath);
}
else if (entry.isFile()) {
totalSize += node_fs_1.default.statSync(fullPath).size;
}
}
}
catch {
// Permission errors or race conditions — return what we have
}
return totalSize;
}
/**
* Format bytes into human-readable string.
*/
static formatBytes(bytes) {
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]}`;
}
}
exports.ChildWorkspaceService = ChildWorkspaceService;
/***/ }), /***/ }),
/***/ 68829: /***/ 68829:
@@ -10386,8 +10750,83 @@ class GitHooksService {
} }
return 'none'; 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) {
const manifestPath = node_path_1.default.join(repoPath, 'Packages', 'manifest.json');
if (!node_fs_1.default.existsSync(manifestPath)) {
return false;
}
try {
const content = node_fs_1.default.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) {
const packageCacheDir = node_path_1.default.join(repoPath, 'Library', 'PackageCache');
if (!node_fs_1.default.existsSync(packageCacheDir)) {
return '';
}
try {
const entries = node_fs_1.default.readdirSync(packageCacheDir);
const match = entries.find((entry) => entry.startsWith(GitHooksService.UNITY_GIT_HOOKS_PACKAGE));
if (match) {
return node_path_1.default.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) {
const packagePath = GitHooksService.findUnityGitHooksPackagePath(repoPath);
if (!packagePath) {
orchestrator_logger_1.default.log(`[GitHooks] Unity Git Hooks package not found in Library/PackageCache, skipping init`);
return;
}
const initScript = node_path_1.default.join(packagePath, '~js', 'init-unity-lefthook.js');
if (!node_fs_1.default.existsSync(initScript)) {
orchestrator_logger_1.default.logWarning(`[GitHooks] Unity Git Hooks init script not found at ${initScript}`);
return;
}
orchestrator_logger_1.default.log(`[GitHooks] Initializing Unity Git Hooks from ${packagePath}`);
try {
await orchestrator_system_1.OrchestratorSystem.Run(`cd "${repoPath}" && node "${initScript}"`, true);
orchestrator_logger_1.default.log(`[GitHooks] Unity Git Hooks initialized successfully`);
}
catch (error) {
orchestrator_logger_1.default.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() {
return {
UNITY_GITHOOKS_BACKGROUND_PROJECT_ENABLED: 'false',
CI: 'true',
};
}
/** /**
* Install git hooks using the detected framework. * 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. * Errors are caught and logged as warnings - hook installation should not fail the build.
*/ */
static async installHooks(repoPath) { static async installHooks(repoPath) {
@@ -10397,6 +10836,16 @@ class GitHooksService {
return; return;
} }
orchestrator_logger_1.default.log(`[GitHooks] Detected hook framework: ${framework}`); orchestrator_logger_1.default.log(`[GitHooks] Detected hook framework: ${framework}`);
// If Unity Git Hooks is present, initialize it before installing hooks
if (framework === 'lefthook' && GitHooksService.detectUnityGitHooks(repoPath)) {
orchestrator_logger_1.default.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 { try {
if (framework === 'lefthook') { if (framework === 'lefthook') {
await orchestrator_system_1.OrchestratorSystem.Run(`cd "${repoPath}" && npx lefthook install`, true); await orchestrator_system_1.OrchestratorSystem.Run(`cd "${repoPath}" && npx lefthook install`, true);
@@ -10411,6 +10860,39 @@ class GitHooksService {
orchestrator_logger_1.default.logWarning(`[GitHooks] Hook installation failed: ${error.message}`); orchestrator_logger_1.default.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, hookGroups) {
const results = {};
if (hookGroups.length === 0) {
return results;
}
const framework = GitHooksService.detectHookFramework(repoPath);
if (framework !== 'lefthook') {
orchestrator_logger_1.default.logWarning(`[GitHooks] runHookGroups requires lefthook, but detected: ${framework}`);
return results;
}
orchestrator_logger_1.default.log(`[GitHooks] Running ${hookGroups.length} hook group(s): ${hookGroups.join(', ')}`);
for (const group of hookGroups) {
try {
await orchestrator_system_1.OrchestratorSystem.Run(`cd "${repoPath}" && npx lefthook run ${group}`, true);
orchestrator_logger_1.default.log(`[GitHooks] Hook group '${group}' passed`);
results[group] = true;
}
catch (error) {
orchestrator_logger_1.default.logWarning(`[GitHooks] Hook group '${group}' failed: ${error.message}`);
results[group] = false;
}
}
return results;
}
/** /**
* Return environment variables that will skip the listed hooks. * Return environment variables that will skip the listed hooks.
* For lefthook: sets LEFTHOOK_EXCLUDE to a comma-separated list. * For lefthook: sets LEFTHOOK_EXCLUDE to a comma-separated list.
@@ -10449,6 +10931,7 @@ class GitHooksService {
} }
} }
exports.GitHooksService = GitHooksService; exports.GitHooksService = GitHooksService;
GitHooksService.UNITY_GIT_HOOKS_PACKAGE = 'com.frostebite.unitygithooks';
/***/ }), /***/ }),
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+40 -2
View File
@@ -25,6 +25,33 @@ async function runMain() {
if (buildParameters.providerStrategy === 'local') { if (buildParameters.providerStrategy === 'local') {
core.info('Building locally'); 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 // Submodule profile initialization
if (buildParameters.submoduleProfilePath) { if (buildParameters.submoduleProfilePath) {
const { SubmoduleProfileService } = await import( const { SubmoduleProfileService } = await import(
@@ -80,8 +107,8 @@ async function runMain() {
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service'); const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
await GitHooksService.installHooks(workspace); await GitHooksService.installHooks(workspace);
if (buildParameters.gitHooksSkipList) { if (buildParameters.gitHooksSkipList) {
const env = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(',')); const environment = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
Object.assign(process.env, env); Object.assign(process.env, environment);
} }
} else { } else {
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service'); const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
@@ -109,6 +136,17 @@ async function runMain() {
await LocalCacheService.saveLfsCache(workspace, cacheRoot, cacheKey); 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 { } else {
await Orchestrator.run(buildParameters, baseImage.toString()); await Orchestrator.run(buildParameters, baseImage.toString());
exitCode = 0; exitCode = 0;
+10
View File
@@ -113,6 +113,11 @@ class BuildParameters {
public localCacheRoot!: string; public localCacheRoot!: string;
public localCacheLibrary!: boolean; public localCacheLibrary!: boolean;
public localCacheLfs!: boolean; public localCacheLfs!: boolean;
public childWorkspacesEnabled!: boolean;
public childWorkspaceName!: string;
public childWorkspaceCacheRoot!: string;
public childWorkspacePreserveGit!: boolean;
public childWorkspaceSeparateLibrary!: boolean;
public lfsTransferAgent!: string; public lfsTransferAgent!: string;
public lfsTransferAgentArgs!: string; public lfsTransferAgentArgs!: string;
public lfsStoragePaths!: string; public lfsStoragePaths!: string;
@@ -263,6 +268,11 @@ class BuildParameters {
localCacheRoot: Input.localCacheRoot, localCacheRoot: Input.localCacheRoot,
localCacheLibrary: Input.localCacheLibrary, localCacheLibrary: Input.localCacheLibrary,
localCacheLfs: Input.localCacheLfs, localCacheLfs: Input.localCacheLfs,
childWorkspacesEnabled: Input.childWorkspacesEnabled,
childWorkspaceName: Input.childWorkspaceName,
childWorkspaceCacheRoot: Input.childWorkspaceCacheRoot,
childWorkspacePreserveGit: Input.childWorkspacePreserveGit,
childWorkspaceSeparateLibrary: Input.childWorkspaceSeparateLibrary,
lfsTransferAgent: Input.lfsTransferAgent, lfsTransferAgent: Input.lfsTransferAgent,
lfsTransferAgentArgs: Input.lfsTransferAgentArgs, lfsTransferAgentArgs: Input.lfsTransferAgentArgs,
lfsStoragePaths: Input.lfsStoragePaths, lfsStoragePaths: Input.lfsStoragePaths,
+20
View File
@@ -310,6 +310,26 @@ class Input {
return (Input.getInput('localCacheLfs') ?? 'false') === 'true'; 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 { static get lfsTransferAgent(): string {
return Input.getInput('lfsTransferAgent') ?? ''; return Input.getInput('lfsTransferAgent') ?? '';
} }
@@ -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);
});
});
});
@@ -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]}`;
}
}