mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-12 08:53:55 -07:00
feat(orchestrator): enterprise feature support — CLI provider, submodule profiles, caching, LFS, hooks
Add generic enterprise-grade features to the orchestrator, enabling Unity projects with complex CI/CD pipelines to adopt game-ci/unity-builder with built-in support for: - CLI provider protocol: JSON-over-stdin/stdout bridge enabling providers in any language (Go, Python, Rust, shell) via the `providerExecutable` input - Submodule profiles: YAML-based selective submodule initialization with glob patterns and variant overlays (`submoduleProfilePath`, `submoduleVariantPath`) - Local build caching: Filesystem-based Library and LFS caching for local builds without external cache actions (`localCacheEnabled`, `localCacheRoot`) - Custom LFS transfer agents: Register external transfer agents like elastic-git-storage (`lfsTransferAgent`, `lfsTransferAgentArgs`, `lfsStoragePaths`) - Git hooks support: Detect and install lefthook/husky with configurable skip lists (`gitHooksEnabled`, `gitHooksSkipList`) Also removes all `orchestrator-develop` branch references, replacing with `main`. 13 new action inputs, 13 new files, 14 new CLI provider tests, 17 submodule tests, plus cache/LFS/hooks unit tests. All 452 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user