Compare commits

..

5 Commits

Author SHA1 Message Date
frostebite
3cac1845e3 fix: replace orchestrator-develop branch references with main
The orchestrator-develop branch no longer exists. Update all fallback
clone commands and test fixtures to use main instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:02:17 +00:00
frostebite
41f00bd1f9 ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:33:21 +00:00
frostebite
7c0c4c2072 fix(hot-runner): validate persisted registry state and add dispatcher safeguards
Validate runner entries when loading from hot-runners.json. Discard
corrupted entries with warnings. Add validateAndRepair() method for
runtime recovery. Validate data before persisting to prevent writing
corrupt state. Handle corrupt persistence files (invalid JSON)
gracefully. Rewrite executeWithTimeout using Promise.race to clean up
transport connections on timeout. Fix pre-existing ESLint violations
in dispatcher and test files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:00:47 +00:00
frostebite
1bb31f3e98 feat(hot-runner): implement hot runner protocol with registry, health monitoring, and job dispatch (#791)
Adds persistent Unity editor instance support to reduce build iteration time
by eliminating cold-start overhead. Includes:

- HotRunnerTypes: interfaces for config, status, job request/result, transport
- HotRunnerRegistry: in-memory runner management with file-based persistence
- HotRunnerHealthMonitor: periodic health checks, idle recycling, job-count recycling
- HotRunnerDispatcher: job routing with wait-for-runner, timeout, and output streaming
- HotRunnerService: high-level API integrating registry, health, and dispatch
- 34 unit tests covering registration, filtering, health, dispatch, timeout, fallback
- action.yml inputs for hot runner configuration (7 new inputs)
- Input/BuildParameters integration for hot runner settings
- index.ts wiring with cold-build fallback when hot runner unavailable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:50:31 +00:00
frostebite
49b37f7831 feat(orchestrator): add hot runner protocol placeholder
Initial scaffold for the runner registration and hot editor provider module.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:05:17 +00:00
17 changed files with 2513 additions and 1816 deletions

View File

@@ -279,30 +279,35 @@ inputs:
description:
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
fixes.'
gitIntegrityCheck:
description: 'Run git integrity checks before build (fsck, lock cleanup, submodule validation)'
hotRunnerEnabled:
description: '[HotRunner] Use persistent hot runner for builds (requires pre-registered runners)'
required: false
default: 'false'
gitAutoRecover:
description: 'Attempt automatic recovery if git corruption is detected'
hotRunnerTransport:
description: '[HotRunner] Transport protocol for hot runner communication: websocket, grpc, named-pipe'
required: false
default: 'false'
cleanReservedFilenames:
description: 'Remove Windows reserved filenames that cause Unity import loops'
default: 'websocket'
hotRunnerHost:
description: '[HotRunner] Hot runner host address'
required: false
default: 'false'
buildArchiveEnabled:
description: 'Archive build output after successful build'
default: 'localhost'
hotRunnerPort:
description: '[HotRunner] Hot runner port number'
required: false
default: 'false'
buildArchivePath:
description: 'Path to store build archives'
required: false
default: './build-archives'
buildArchiveRetention:
description: 'Days to retain build archives before cleanup'
default: '9090'
hotRunnerHealthInterval:
description: '[HotRunner] Health check interval in seconds'
required: false
default: '30'
hotRunnerMaxIdle:
description: '[HotRunner] Maximum idle time in seconds before recycling runner'
required: false
default: '3600'
hotRunnerFallbackToCold:
description: '[HotRunner] Fall back to cold build if no hot runner available'
required: false
default: 'true'
outputs:
volume:

1302
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

@@ -3,7 +3,8 @@ import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output
import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup';
import { BuildReliabilityService } from './model/orchestrator/services/reliability';
import { HotRunnerService } from './model/orchestrator/services/hot-runner';
import { HotRunnerConfig } from './model/orchestrator/services/hot-runner/hot-runner-types';
async function runMain() {
try {
@@ -15,63 +16,58 @@ async function runMain() {
Action.checkCompatibility();
Cache.verify();
// Always configure git environment for CI reliability
BuildReliabilityService.configureGitEnvironment();
const { workspace, actionFolder } = Action;
const buildParameters = await BuildParameters.create();
const baseImage = new ImageTag(buildParameters);
// Pre-build reliability checks
if (buildParameters.gitIntegrityCheck) {
core.info('Running git integrity checks...');
const isHealthy = BuildReliabilityService.checkGitIntegrity(workspace);
BuildReliabilityService.cleanStaleLockFiles(workspace);
BuildReliabilityService.validateSubmoduleBackingStores(workspace);
if (buildParameters.cleanReservedFilenames) {
BuildReliabilityService.cleanReservedFilenames(buildParameters.projectPath);
}
if (!isHealthy && buildParameters.gitAutoRecover) {
core.info('Git corruption detected, attempting automatic recovery...');
const recovered = BuildReliabilityService.recoverCorruptedRepo(workspace);
if (!recovered) {
core.warning('Automatic recovery failed. Build may encounter issues.');
}
}
} else if (buildParameters.cleanReservedFilenames) {
// cleanReservedFilenames can run independently of gitIntegrityCheck
BuildReliabilityService.cleanReservedFilenames(buildParameters.projectPath);
}
let exitCode = -1;
if (buildParameters.providerStrategy === 'local') {
// Hot runner path: attempt to use a persistent Unity editor instance
if (buildParameters.hotRunnerEnabled) {
core.info('[HotRunner] Hot runner mode enabled, attempting hot build...');
const hotRunnerConfig: HotRunnerConfig = {
enabled: true,
transport: buildParameters.hotRunnerTransport,
host: buildParameters.hotRunnerHost,
port: buildParameters.hotRunnerPort,
healthCheckInterval: buildParameters.hotRunnerHealthInterval,
maxIdleTime: buildParameters.hotRunnerMaxIdle,
maxJobsBeforeRecycle: 0, // no automatic recycle by job count
};
const hotRunnerService = new HotRunnerService();
try {
await hotRunnerService.initialize(hotRunnerConfig);
const result = await hotRunnerService.submitBuild(buildParameters, (output) => {
core.info(output);
});
exitCode = result.exitCode;
core.info(`[HotRunner] Build completed with exit code ${exitCode}`);
await hotRunnerService.shutdown();
} catch (hotRunnerError) {
await hotRunnerService.shutdown();
if (buildParameters.hotRunnerFallbackToCold) {
core.warning(
`[HotRunner] Hot runner failed: ${(hotRunnerError as Error).message}. Falling back to cold build.`,
);
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
} else {
throw hotRunnerError;
}
}
} else if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
await PlatformSetup.setup(buildParameters, actionFolder);
exitCode =
process.platform === 'darwin'
? await MacBuilder.run(actionFolder)
: await Docker.run(baseImage.toString(), {
workspace,
actionFolder,
...buildParameters,
});
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
} else {
await Orchestrator.run(buildParameters, baseImage.toString());
exitCode = 0;
}
// Post-build: archive and enforce retention
if (buildParameters.buildArchiveEnabled && exitCode === 0) {
core.info('Archiving build output...');
BuildReliabilityService.archiveBuildOutput(buildParameters.buildPath, buildParameters.buildArchivePath);
BuildReliabilityService.enforceRetention(buildParameters.buildArchivePath, buildParameters.buildArchiveRetention);
}
// Set output
await Output.setBuildVersion(buildParameters.buildVersion);
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
@@ -85,4 +81,28 @@ async function runMain() {
}
}
async function runColdBuild(
buildParameters: BuildParameters,
baseImage: ImageTag,
workspace: string,
actionFolder: string,
): Promise<number> {
if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
await PlatformSetup.setup(buildParameters, actionFolder);
return process.platform === 'darwin'
? await MacBuilder.run(actionFolder)
: await Docker.run(baseImage.toString(), {
workspace,
actionFolder,
...buildParameters,
});
} else {
await Orchestrator.run(buildParameters, baseImage.toString());
return 0;
}
}
runMain();

View File

@@ -106,12 +106,13 @@ class BuildParameters {
public cacheUnityInstallationOnMac!: boolean;
public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: string;
public gitIntegrityCheck!: boolean;
public gitAutoRecover!: boolean;
public cleanReservedFilenames!: boolean;
public buildArchiveEnabled!: boolean;
public buildArchivePath!: string;
public buildArchiveRetention!: number;
public hotRunnerEnabled!: boolean;
public hotRunnerTransport!: 'websocket' | 'grpc' | 'named-pipe';
public hotRunnerHost!: string;
public hotRunnerPort!: number;
public hotRunnerHealthInterval!: number;
public hotRunnerMaxIdle!: number;
public hotRunnerFallbackToCold!: boolean;
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
@@ -248,12 +249,13 @@ class BuildParameters {
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath,
gitIntegrityCheck: Input.gitIntegrityCheck,
gitAutoRecover: Input.gitAutoRecover,
cleanReservedFilenames: Input.cleanReservedFilenames,
buildArchiveEnabled: Input.buildArchiveEnabled,
buildArchivePath: Input.buildArchivePath,
buildArchiveRetention: Input.buildArchiveRetention,
hotRunnerEnabled: Input.hotRunnerEnabled,
hotRunnerTransport: Input.hotRunnerTransport,
hotRunnerHost: Input.hotRunnerHost,
hotRunnerPort: Input.hotRunnerPort,
hotRunnerHealthInterval: Input.hotRunnerHealthInterval,
hotRunnerMaxIdle: Input.hotRunnerMaxIdle,
hotRunnerFallbackToCold: Input.hotRunnerFallbackToCold,
};
}

View File

@@ -282,38 +282,38 @@ class Input {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
static get gitIntegrityCheck(): boolean {
const input = Input.getInput('gitIntegrityCheck') ?? 'false';
static get hotRunnerEnabled(): boolean {
const input = Input.getInput('hotRunnerEnabled') ?? false;
return input === 'true';
}
static get gitAutoRecover(): boolean {
const input = Input.getInput('gitAutoRecover') ?? 'false';
static get hotRunnerTransport(): 'websocket' | 'grpc' | 'named-pipe' {
return (Input.getInput('hotRunnerTransport') ?? 'websocket') as 'websocket' | 'grpc' | 'named-pipe';
}
static get hotRunnerHost(): string {
return Input.getInput('hotRunnerHost') ?? 'localhost';
}
static get hotRunnerPort(): number {
return Number.parseInt(Input.getInput('hotRunnerPort') ?? '9090', 10);
}
static get hotRunnerHealthInterval(): number {
return Number.parseInt(Input.getInput('hotRunnerHealthInterval') ?? '30', 10);
}
static get hotRunnerMaxIdle(): number {
return Number.parseInt(Input.getInput('hotRunnerMaxIdle') ?? '3600', 10);
}
static get hotRunnerFallbackToCold(): boolean {
const input = Input.getInput('hotRunnerFallbackToCold') ?? 'true';
return input === 'true';
}
static get cleanReservedFilenames(): boolean {
const input = Input.getInput('cleanReservedFilenames') ?? 'false';
return input === 'true';
}
static get buildArchiveEnabled(): boolean {
const input = Input.getInput('buildArchiveEnabled') ?? 'false';
return input === 'true';
}
static get buildArchivePath(): string {
return Input.getInput('buildArchivePath') ?? './build-archives';
}
static get buildArchiveRetention(): number {
return Number.parseInt(Input.getInput('buildArchiveRetention') ?? '30', 10);
}
public static ToEnvVarFormat(input: string) {
if (input.toUpperCase() === input) {
return input;

View File

@@ -0,0 +1,5 @@
# Hot Runner Protocol
Extensible runner registration and persistent Unity editor provider protocol.
See GitHub Issue for full specification.

View File

@@ -0,0 +1,159 @@
import OrchestratorLogger from '../core/orchestrator-logger';
import { HotRunnerRegistry } from './hot-runner-registry';
import { HotRunnerJobRequest, HotRunnerJobResult, HotRunnerStatus, HotRunnerTransport } from './hot-runner-types';
const POLL_INTERVAL_MS = 1000;
// eslint-disable-next-line no-unused-vars
export type OutputCallback = (output: string) => void;
export class HotRunnerDispatcher {
private transports: Map<string, HotRunnerTransport>;
constructor(transports: Map<string, HotRunnerTransport>) {
this.transports = transports;
}
/**
* Dispatch a job to an available hot runner matching the request's build target.
* If no runner is immediately available, waits up to the request timeout.
* Returns the job result, or throws if no runner becomes available in time.
*/
async dispatchJob(
request: HotRunnerJobRequest,
registry: HotRunnerRegistry,
unityVersion: string,
onOutput?: OutputCallback,
): Promise<HotRunnerJobResult> {
OrchestratorLogger.log(`[HotRunner] Dispatching job ${request.jobId} (target: ${request.buildTarget})`);
// Find or wait for an available runner
let runner = registry.findAvailableRunner({
unityVersion,
platform: request.buildTarget,
});
if (!runner) {
OrchestratorLogger.log(
`[HotRunner] No idle runner available for ${unityVersion}/${request.buildTarget}, waiting...`,
);
runner = await this.waitForRunner({ unityVersion, platform: request.buildTarget }, request.timeout, registry);
}
// Mark runner as busy
registry.updateRunner(runner.id, {
state: 'busy',
currentJob: request.jobId,
});
const transport = this.transports.get(runner.id);
if (!transport) {
registry.updateRunner(runner.id, { state: 'idle', currentJob: undefined });
throw new Error(`[HotRunner] No transport available for runner ${runner.id}`);
}
OrchestratorLogger.log(`[HotRunner] Sending job ${request.jobId} to runner ${runner.id}`);
const startTime = Date.now();
try {
const result = await this.executeWithTimeout(transport, request);
const duration = Date.now() - startTime;
OrchestratorLogger.log(
`[HotRunner] Job ${request.jobId} completed on runner ${runner.id} in ${duration}ms (exit: ${result.exitCode})`,
);
if (onOutput && result.output) {
onOutput(result.output);
}
// Mark runner as idle and increment job count
const currentStatus = registry.getRunner(runner.id);
registry.updateRunner(runner.id, {
state: 'idle',
currentJob: undefined,
lastJobCompleted: request.jobId,
jobsCompleted: (currentStatus?.jobsCompleted ?? 0) + 1,
});
return result;
} catch (error: any) {
OrchestratorLogger.logWarning(`[HotRunner] Job ${request.jobId} failed on runner ${runner.id}: ${error.message}`);
// Mark runner as idle despite failure -- the health monitor will recycle if needed
registry.updateRunner(runner.id, {
state: 'idle',
currentJob: undefined,
});
throw error;
}
}
/**
* Wait for an available runner matching the requirements.
* Polls the registry at a fixed interval until one becomes available or timeout expires.
*/
async waitForRunner(
requirements: { unityVersion: string; platform: string },
timeoutMs: number,
registry: HotRunnerRegistry,
): Promise<HotRunnerStatus> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const runner = registry.findAvailableRunner(requirements);
if (runner) {
OrchestratorLogger.log(`[HotRunner] Runner ${runner.id} became available`);
return runner;
}
await this.sleep(Math.min(POLL_INTERVAL_MS, deadline - Date.now()));
}
throw new Error(
`[HotRunner] Timed out waiting for available runner (${requirements.unityVersion}/${requirements.platform}) after ${timeoutMs}ms`,
);
}
/**
* Execute a job on a transport with a timeout guard.
* On timeout, disconnects the transport to release the connection
* and prevent the orphaned sendJob promise from holding resources.
*/
private async executeWithTimeout(
transport: HotRunnerTransport,
request: HotRunnerJobRequest,
): Promise<HotRunnerJobResult> {
const TIMEOUT_SENTINEL = Symbol('timeout');
const timeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>((resolve) => {
setTimeout(() => {
resolve(TIMEOUT_SENTINEL);
}, request.timeout);
});
const result = await Promise.race([transport.sendJob(request), timeoutPromise]);
if (result === TIMEOUT_SENTINEL) {
// Disconnect the transport to clean up the orphaned sendJob call
try {
await transport.disconnect();
} catch (disconnectError: any) {
OrchestratorLogger.logWarning(
`[HotRunner] Error disconnecting transport after timeout for job ${request.jobId}: ${disconnectError.message}`,
);
}
throw new Error(`[HotRunner] Job ${request.jobId} timed out after ${request.timeout}ms`);
}
return result;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,186 @@
import OrchestratorLogger from '../core/orchestrator-logger';
import { HotRunnerRegistry } from './hot-runner-registry';
import { HotRunnerTransport } from './hot-runner-types';
export class HotRunnerHealthMonitor {
private intervalHandle: ReturnType<typeof setInterval> | undefined;
private registry: HotRunnerRegistry | undefined;
private transports: Map<string, HotRunnerTransport> = new Map();
/**
* Start periodic health monitoring for all registered runners.
*/
startMonitoring(registry: HotRunnerRegistry, interval: number, transports: Map<string, HotRunnerTransport>): void {
if (this.intervalHandle) {
this.stopMonitoring();
}
this.registry = registry;
this.transports = transports;
OrchestratorLogger.log(`[HotRunner] Starting health monitoring (interval: ${interval}s)`);
this.intervalHandle = setInterval(() => {
this.runHealthChecks().catch((error: any) => {
OrchestratorLogger.logWarning(`[HotRunner] Health check cycle failed: ${error.message}`);
});
}, interval * 1000);
}
/**
* Stop periodic health monitoring.
*/
stopMonitoring(): void {
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = undefined;
OrchestratorLogger.log(`[HotRunner] Health monitoring stopped`);
}
}
/**
* Check health of a specific runner by ID. Returns true if healthy.
*/
async checkHealth(runnerId: string): Promise<boolean> {
if (!this.registry) {
return false;
}
const transport = this.transports.get(runnerId);
if (!transport) {
OrchestratorLogger.logWarning(`[HotRunner] No transport for runner ${runnerId}`);
this.registry.updateRunner(runnerId, {
state: 'unhealthy',
lastHealthCheck: new Date().toISOString(),
});
return false;
}
try {
const healthy = await transport.healthCheck();
if (healthy) {
const status = await transport.getStatus();
this.registry.updateRunner(runnerId, {
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: status.memoryUsageMB,
uptime: status.uptime,
libraryHash: status.libraryHash,
});
return true;
}
OrchestratorLogger.logWarning(`[HotRunner] Runner ${runnerId} health check returned false`);
this.registry.updateRunner(runnerId, {
state: 'unhealthy',
lastHealthCheck: new Date().toISOString(),
});
return false;
} catch (error: any) {
OrchestratorLogger.logWarning(`[HotRunner] Runner ${runnerId} health check failed: ${error.message}`);
this.registry.updateRunner(runnerId, {
state: 'unhealthy',
lastHealthCheck: new Date().toISOString(),
});
return false;
}
}
/**
* Mark an unhealthy runner for cleanup and disconnect its transport.
*/
async recycleUnhealthyRunner(runnerId: string): Promise<void> {
if (!this.registry) {
return;
}
OrchestratorLogger.log(`[HotRunner] Recycling unhealthy runner ${runnerId}`);
this.registry.updateRunner(runnerId, { state: 'stopping' });
const transport = this.transports.get(runnerId);
if (transport) {
try {
await transport.disconnect();
} catch (error: any) {
OrchestratorLogger.logWarning(`[HotRunner] Error disconnecting runner ${runnerId}: ${error.message}`);
}
this.transports.delete(runnerId);
}
this.registry.unregisterRunner(runnerId);
OrchestratorLogger.log(`[HotRunner] Runner ${runnerId} recycled and removed`);
}
/**
* Recycle a runner that has been idle longer than the maximum idle time.
*/
async recycleIdleRunner(runnerId: string, maxIdleTime: number): Promise<void> {
if (!this.registry) {
return;
}
const runner = this.registry.getRunner(runnerId);
if (!runner || runner.state !== 'idle') {
return;
}
const lastCheckTime = new Date(runner.lastHealthCheck).getTime();
const now = Date.now();
const idleSeconds = (now - lastCheckTime) / 1000;
if (idleSeconds >= maxIdleTime) {
OrchestratorLogger.log(
`[HotRunner] Runner ${runnerId} idle for ${Math.floor(idleSeconds)}s (max: ${maxIdleTime}s), recycling`,
);
await this.recycleUnhealthyRunner(runnerId);
}
}
/**
* Run health checks and idle-recycle checks for all registered runners.
*/
private async runHealthChecks(): Promise<void> {
if (!this.registry) {
return;
}
const runners = this.registry.listRunners();
for (const runner of runners) {
if (runner.state === 'stopping') {
continue;
}
const healthy = await this.checkHealth(runner.id);
if (!healthy && runner.state !== 'starting') {
await this.recycleUnhealthyRunner(runner.id);
continue;
}
// Check for idle timeout
const config = this.registry.getConfig(runner.id);
if (config && runner.state === 'idle') {
await this.recycleIdleRunner(runner.id, config.maxIdleTime);
}
// Check for max jobs before recycle
if (config && config.maxJobsBeforeRecycle > 0 && runner.jobsCompleted >= config.maxJobsBeforeRecycle) {
OrchestratorLogger.log(
`[HotRunner] Runner ${runner.id} reached max jobs (${runner.jobsCompleted}/${config.maxJobsBeforeRecycle}), recycling`,
);
await this.recycleUnhealthyRunner(runner.id);
}
}
}
/**
* Whether health monitoring is currently active.
*/
get isMonitoring(): boolean {
return this.intervalHandle !== undefined;
}
}

View File

@@ -0,0 +1,315 @@
import fs from 'node:fs';
import path from 'node:path';
import { customAlphabet } from 'nanoid';
import OrchestratorLogger from '../core/orchestrator-logger';
import { HotRunnerConfig, HotRunnerStatus } from './hot-runner-types';
const generateId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 12);
const PERSISTENCE_FILENAME = 'hot-runners.json';
const VALID_RUNNER_STATES: ReadonlySet<string> = new Set(['idle', 'busy', 'starting', 'stopping', 'unhealthy']);
export interface HotRunnerFilter {
platform?: string;
state?: string;
unityVersion?: string;
}
/**
* Validate that a restored runner entry has all required fields with correct types.
* Returns true if the entry is a valid HotRunnerStatus, false otherwise.
*/
function isValidRunnerStatus(entry: unknown): entry is HotRunnerStatus {
if (typeof entry !== 'object' || entry === null) {
return false;
}
const record = entry as Record<string, unknown>;
return (
typeof record.id === 'string' &&
record.id.length > 0 &&
typeof record.state === 'string' &&
VALID_RUNNER_STATES.has(record.state) &&
typeof record.unityVersion === 'string' &&
typeof record.platform === 'string' &&
typeof record.uptime === 'number' &&
typeof record.jobsCompleted === 'number' &&
typeof record.lastHealthCheck === 'string' &&
typeof record.memoryUsageMB === 'number'
);
}
/**
* Validate that a restored config entry has all required fields with correct types.
* Returns true if the entry is a valid HotRunnerConfig, false otherwise.
*/
function isValidRunnerConfig(entry: unknown): entry is HotRunnerConfig {
if (typeof entry !== 'object' || entry === null) {
return false;
}
const record = entry as Record<string, unknown>;
return (
typeof record.enabled === 'boolean' &&
typeof record.transport === 'string' &&
['websocket', 'grpc', 'named-pipe'].includes(record.transport) &&
typeof record.host === 'string' &&
typeof record.port === 'number' &&
typeof record.healthCheckInterval === 'number' &&
typeof record.maxIdleTime === 'number' &&
typeof record.maxJobsBeforeRecycle === 'number'
);
}
export class HotRunnerRegistry {
private runners: Map<string, HotRunnerStatus> = new Map();
private configs: Map<string, HotRunnerConfig> = new Map();
private persistencePath: string;
constructor(persistenceDirectory?: string) {
this.persistencePath = persistenceDirectory ? path.join(persistenceDirectory, PERSISTENCE_FILENAME) : '';
}
/**
* Register a new hot runner. Returns the generated runner ID.
*/
registerRunner(config: HotRunnerConfig): string {
const id = `hr-${generateId()}`;
const status: HotRunnerStatus = {
id,
state: 'starting',
unityVersion: config.unityVersion ?? 'unknown',
platform: config.platform ?? 'unknown',
uptime: 0,
jobsCompleted: 0,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 0,
};
this.runners.set(id, status);
this.configs.set(id, config);
OrchestratorLogger.log(`[HotRunner] Registered runner ${id} (${status.unityVersion}/${status.platform})`);
this.persist();
return id;
}
/**
* Remove a runner from the registry.
*/
unregisterRunner(id: string): void {
const existed = this.runners.delete(id);
this.configs.delete(id);
if (existed) {
OrchestratorLogger.log(`[HotRunner] Unregistered runner ${id}`);
this.persist();
}
}
/**
* Get a runner's current status by ID.
*/
getRunner(id: string): HotRunnerStatus | undefined {
return this.runners.get(id);
}
/**
* Get a runner's config by ID.
*/
getConfig(id: string): HotRunnerConfig | undefined {
return this.configs.get(id);
}
/**
* List all runners, optionally filtered by platform, state, or Unity version.
*/
listRunners(filter?: HotRunnerFilter): HotRunnerStatus[] {
let results = [...this.runners.values()];
if (filter?.platform) {
results = results.filter((runner) => runner.platform === filter.platform);
}
if (filter?.state) {
results = results.filter((runner) => runner.state === filter.state);
}
if (filter?.unityVersion) {
results = results.filter((runner) => runner.unityVersion === filter.unityVersion);
}
return results;
}
/**
* Find an idle runner matching the given Unity version and platform requirements.
*/
findAvailableRunner(requirements: { unityVersion: string; platform: string }): HotRunnerStatus | undefined {
return this.listRunners({
state: 'idle',
unityVersion: requirements.unityVersion,
platform: requirements.platform,
})[0];
}
/**
* Update a runner's status fields. Merges partial updates into existing status.
*/
updateRunner(id: string, update: Partial<HotRunnerStatus>): void {
const existing = this.runners.get(id);
if (!existing) {
return;
}
this.runners.set(id, { ...existing, ...update, id });
this.persist();
}
/**
* Get the total number of registered runners.
*/
get size(): number {
return this.runners.size;
}
/**
* Validate all runners in the registry and reset invalid ones to 'unhealthy'.
* Returns the number of runners that were repaired.
*/
validateAndRepair(): number {
let repaired = 0;
for (const [id, status] of this.runners) {
// Cast to unknown to bypass the type guard narrowing to 'never',
// since the Map is typed as HotRunnerStatus but entries may have
// been corrupted via direct deserialization or unsafe casts.
const entry = status as unknown as Record<string, unknown>;
if (!isValidRunnerStatus(entry)) {
OrchestratorLogger.logWarning(`[HotRunner] Runner ${id} has invalid state, marking as unhealthy`);
this.runners.set(id, {
id,
state: 'unhealthy',
unityVersion: typeof entry.unityVersion === 'string' ? entry.unityVersion : 'unknown',
platform: typeof entry.platform === 'string' ? entry.platform : 'unknown',
uptime: typeof entry.uptime === 'number' ? entry.uptime : 0,
jobsCompleted: typeof entry.jobsCompleted === 'number' ? entry.jobsCompleted : 0,
lastHealthCheck: typeof entry.lastHealthCheck === 'string' ? entry.lastHealthCheck : new Date().toISOString(),
memoryUsageMB: typeof entry.memoryUsageMB === 'number' ? entry.memoryUsageMB : 0,
});
repaired++;
}
}
if (repaired > 0) {
this.persist();
}
return repaired;
}
/**
* Persist current registry state to disk for crash recovery.
* Validates data before writing to prevent persisting corrupt state.
*/
private persist(): void {
if (!this.persistencePath) {
return;
}
try {
// Validate data before persisting
for (const [id, status] of this.runners) {
if (!isValidRunnerStatus(status)) {
OrchestratorLogger.logWarning(`[HotRunner] Skipping persistence -- runner ${id} has invalid state`);
return;
}
}
const data = {
runners: Object.fromEntries(this.runners),
configs: Object.fromEntries(this.configs),
};
const directory = path.dirname(this.persistencePath);
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, { recursive: true });
}
fs.writeFileSync(this.persistencePath, JSON.stringify(data, undefined, 2));
} catch (error: any) {
OrchestratorLogger.logWarning(`[HotRunner] Failed to persist registry: ${error.message}`);
}
}
/**
* Load registry state from disk. Returns the number of runners restored.
* Validates each restored entry and discards corrupt entries with warnings.
* If the persistence file itself is corrupt (invalid JSON), starts with
* an empty registry.
*/
loadFromDisk(): number {
if (!this.persistencePath || !fs.existsSync(this.persistencePath)) {
return 0;
}
let data: any;
try {
const raw = fs.readFileSync(this.persistencePath, 'utf8');
data = JSON.parse(raw);
} catch (error: any) {
OrchestratorLogger.logWarning(
`[HotRunner] Persistence file is corrupt, starting with empty registry: ${error.message}`,
);
return 0;
}
if (typeof data !== 'object' || data === null) {
OrchestratorLogger.logWarning('[HotRunner] Persistence file has invalid structure, starting with empty registry');
return 0;
}
let discarded = 0;
if (data.runners && typeof data.runners === 'object') {
for (const [id, status] of Object.entries(data.runners)) {
if (isValidRunnerStatus(status)) {
this.runners.set(id, status);
} else {
OrchestratorLogger.logWarning(`[HotRunner] Discarding invalid runner entry '${id}' from persistence file`);
discarded++;
}
}
}
if (data.configs && typeof data.configs === 'object') {
for (const [id, config] of Object.entries(data.configs)) {
// Only restore configs for runners that were successfully restored
if (this.runners.has(id)) {
if (isValidRunnerConfig(config)) {
this.configs.set(id, config);
} else {
OrchestratorLogger.logWarning(`[HotRunner] Discarding invalid config entry '${id}' from persistence file`);
}
}
}
}
if (discarded > 0) {
OrchestratorLogger.logWarning(`[HotRunner] Discarded ${discarded} invalid runner(s) from persistence file`);
}
OrchestratorLogger.log(`[HotRunner] Restored ${this.runners.size} runner(s) from disk`);
return this.runners.size;
}
}

View File

@@ -0,0 +1,166 @@
import OrchestratorLogger from '../core/orchestrator-logger';
import { HotRunnerRegistry } from './hot-runner-registry';
import { HotRunnerHealthMonitor } from './hot-runner-health-monitor';
import { HotRunnerDispatcher, OutputCallback } from './hot-runner-dispatcher';
import {
HotRunnerConfig,
HotRunnerJobRequest,
HotRunnerJobResult,
HotRunnerStatus,
HotRunnerTransport,
} from './hot-runner-types';
import BuildParameters from '../../../build-parameters';
export class HotRunnerService {
private registry: HotRunnerRegistry;
private healthMonitor: HotRunnerHealthMonitor;
private dispatcher: HotRunnerDispatcher;
private transports: Map<string, HotRunnerTransport> = new Map();
private config: HotRunnerConfig | undefined;
constructor(persistenceDirectory?: string) {
this.registry = new HotRunnerRegistry(persistenceDirectory);
this.healthMonitor = new HotRunnerHealthMonitor();
this.dispatcher = new HotRunnerDispatcher(this.transports);
}
/**
* Initialize the hot runner service: load persisted state, start health monitoring.
*/
async initialize(config: HotRunnerConfig): Promise<void> {
this.config = config;
OrchestratorLogger.log(
`[HotRunner] Initializing service (transport: ${config.transport}, ${config.host}:${config.port})`,
);
// Attempt to restore previously registered runners from disk
const restored = this.registry.loadFromDisk();
if (restored > 0) {
OrchestratorLogger.log(`[HotRunner] Restored ${restored} runner(s) from persistence`);
}
// Start health monitoring
this.healthMonitor.startMonitoring(this.registry, config.healthCheckInterval, this.transports);
OrchestratorLogger.log(`[HotRunner] Service initialized`);
}
/**
* Register a runner with a transport implementation.
* Returns the runner ID.
*/
registerRunner(config: HotRunnerConfig, transport: HotRunnerTransport): string {
const id = this.registry.registerRunner(config);
this.transports.set(id, transport);
return id;
}
/**
* Submit a build job to an available hot runner.
* Converts BuildParameters to a HotRunnerJobRequest and dispatches.
*/
async submitBuild(params: BuildParameters, onOutput?: OutputCallback): Promise<HotRunnerJobResult> {
const request: HotRunnerJobRequest = {
jobId: params.buildGuid || `build-${Date.now()}`,
buildMethod: params.buildMethod || undefined,
buildTarget: params.targetPlatform,
buildPath: params.buildPath,
customParameters: params.customParameters ? this.parseCustomParameters(params.customParameters) : undefined,
timeout: 30 * 60 * 1000, // 30 minutes default
};
OrchestratorLogger.log(`[HotRunner] Submitting build: ${request.jobId} (target: ${request.buildTarget})`);
return this.dispatcher.dispatchJob(request, this.registry, params.editorVersion, onOutput);
}
/**
* Submit a test job to an available hot runner.
* Converts BuildParameters and optional suite config to a test-mode HotRunnerJobRequest.
*/
async submitTest(
params: BuildParameters,
suiteConfig?: { testMode?: 'editmode' | 'playmode'; testSuitePath?: string },
onOutput?: OutputCallback,
): Promise<HotRunnerJobResult> {
const request: HotRunnerJobRequest = {
jobId: params.buildGuid || `test-${Date.now()}`,
buildTarget: params.targetPlatform,
customParameters: params.customParameters ? this.parseCustomParameters(params.customParameters) : undefined,
timeout: 30 * 60 * 1000, // 30 minutes default
testMode: suiteConfig?.testMode ?? 'editmode',
testSuitePath: suiteConfig?.testSuitePath,
};
OrchestratorLogger.log(`[HotRunner] Submitting test: ${request.jobId} (mode: ${request.testMode})`);
return this.dispatcher.dispatchJob(request, this.registry, params.editorVersion, onOutput);
}
/**
* Shut down the service: stop health monitoring, disconnect all transports,
* and unregister all runners.
*/
async shutdown(): Promise<void> {
OrchestratorLogger.log(`[HotRunner] Shutting down service`);
this.healthMonitor.stopMonitoring();
const disconnectPromises: Promise<void>[] = [];
for (const [id, transport] of this.transports.entries()) {
disconnectPromises.push(
transport.disconnect().catch((error: any) => {
OrchestratorLogger.logWarning(`[HotRunner] Error disconnecting runner ${id}: ${error.message}`);
}),
);
}
await Promise.all(disconnectPromises);
this.transports.clear();
OrchestratorLogger.log(`[HotRunner] Service shut down`);
}
/**
* Get the status of all registered runners.
*/
getStatus(): HotRunnerStatus[] {
return this.registry.listRunners();
}
/**
* Get the underlying registry (for testing or advanced use).
*/
getRegistry(): HotRunnerRegistry {
return this.registry;
}
/**
* Parse a space-separated custom parameters string into a key-value map.
* Handles `-key value` and `-key=value` formats.
*/
private parseCustomParameters(raw: string): Record<string, string> {
const result: Record<string, string> = {};
const parts = raw.trim().split(/\s+/);
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.startsWith('-')) {
const key = part.replace(/^-+/, '');
if (key.includes('=')) {
const [k, ...v] = key.split('=');
result[k] = v.join('=');
} else if (i + 1 < parts.length && !parts[i + 1].startsWith('-')) {
result[key] = parts[i + 1];
i++;
} else {
result[key] = 'true';
}
}
}
return result;
}
}

View File

@@ -0,0 +1,54 @@
export interface HotRunnerConfig {
enabled: boolean;
transport: 'websocket' | 'grpc' | 'named-pipe';
host: string;
port: number;
healthCheckInterval: number; // seconds
maxIdleTime: number; // seconds before recycling
maxJobsBeforeRecycle: number;
unityVersion?: string;
platform?: string;
}
export interface HotRunnerStatus {
id: string;
state: 'idle' | 'busy' | 'starting' | 'stopping' | 'unhealthy';
unityVersion: string;
platform: string;
currentJob?: string;
lastJobCompleted?: string;
uptime: number;
jobsCompleted: number;
lastHealthCheck: string;
memoryUsageMB: number;
libraryHash?: string;
}
export interface HotRunnerJobRequest {
jobId: string;
buildMethod?: string;
buildTarget: string;
buildPath?: string;
customParameters?: Record<string, string>;
timeout: number;
testMode?: 'editmode' | 'playmode';
testSuitePath?: string;
}
export interface HotRunnerJobResult {
jobId: string;
success: boolean;
exitCode: number;
duration: number;
output: string;
artifacts?: string[];
testResults?: string; // path to test result file
}
export interface HotRunnerTransport {
connect(config: HotRunnerConfig): Promise<void>;
disconnect(): Promise<void>;
sendJob(request: HotRunnerJobRequest): Promise<HotRunnerJobResult>;
getStatus(): Promise<HotRunnerStatus>;
healthCheck(): Promise<boolean>;
}

View File

@@ -0,0 +1,740 @@
import fs from 'node:fs';
import { HotRunnerRegistry } from './hot-runner-registry';
import { HotRunnerHealthMonitor } from './hot-runner-health-monitor';
import { HotRunnerDispatcher } from './hot-runner-dispatcher';
import { HotRunnerService } from './hot-runner-service';
import {
HotRunnerConfig,
HotRunnerJobRequest,
HotRunnerJobResult,
HotRunnerStatus,
HotRunnerTransport,
} from './hot-runner-types';
// Mock dependencies
jest.mock('node:fs');
jest.mock('../core/orchestrator-logger');
const mockFs = fs as jest.Mocked<typeof fs>;
function createMockConfig(overrides?: Partial<HotRunnerConfig>): HotRunnerConfig {
return {
enabled: true,
transport: 'websocket',
host: 'localhost',
port: 9090,
healthCheckInterval: 30,
maxIdleTime: 3600,
maxJobsBeforeRecycle: 100,
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
...overrides,
};
}
function createMockTransport(overrides?: Partial<HotRunnerTransport>): HotRunnerTransport {
return {
// eslint-disable-next-line unicorn/no-useless-undefined
connect: jest.fn().mockResolvedValue(undefined),
// eslint-disable-next-line unicorn/no-useless-undefined
disconnect: jest.fn().mockResolvedValue(undefined),
sendJob: jest.fn().mockResolvedValue({
jobId: 'test-job',
success: true,
exitCode: 0,
duration: 5000,
output: 'Build succeeded',
artifacts: ['build/output.exe'],
} as HotRunnerJobResult),
getStatus: jest.fn().mockResolvedValue({
id: 'mock-runner',
state: 'idle',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 3600,
jobsCompleted: 5,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 1024,
} as HotRunnerStatus),
healthCheck: jest.fn().mockResolvedValue(true),
...overrides,
};
}
function createMockJobRequest(overrides?: Partial<HotRunnerJobRequest>): HotRunnerJobRequest {
return {
jobId: 'job-001',
buildTarget: 'StandaloneWindows64',
timeout: 60000,
...overrides,
};
}
// --- Registry Tests ---
describe('HotRunnerRegistry', () => {
let registry: HotRunnerRegistry;
beforeEach(() => {
jest.clearAllMocks();
registry = new HotRunnerRegistry();
});
it('should register a runner and return an ID', () => {
const config = createMockConfig();
const id = registry.registerRunner(config);
expect(id).toMatch(/^hr-/);
expect(registry.size).toBe(1);
});
it('should retrieve a registered runner by ID', () => {
const config = createMockConfig();
const id = registry.registerRunner(config);
const runner = registry.getRunner(id);
expect(runner).toBeDefined();
expect(runner!.id).toBe(id);
expect(runner!.state).toBe('starting');
expect(runner!.unityVersion).toBe('2022.3.0f1');
expect(runner!.platform).toBe('StandaloneWindows64');
});
it('should return undefined for unknown runner ID', () => {
const runner = registry.getRunner('nonexistent');
expect(runner).toBeUndefined();
});
it('should unregister a runner', () => {
const id = registry.registerRunner(createMockConfig());
expect(registry.size).toBe(1);
registry.unregisterRunner(id);
expect(registry.size).toBe(0);
expect(registry.getRunner(id)).toBeUndefined();
});
it('should handle unregistering a nonexistent runner gracefully', () => {
registry.unregisterRunner('nonexistent');
expect(registry.size).toBe(0);
});
it('should list all runners without filter', () => {
registry.registerRunner(createMockConfig({ platform: 'StandaloneWindows64' }));
registry.registerRunner(createMockConfig({ platform: 'StandaloneLinux64' }));
registry.registerRunner(createMockConfig({ platform: 'StandaloneOSX' }));
const all = registry.listRunners();
expect(all).toHaveLength(3);
});
it('should filter runners by platform', () => {
registry.registerRunner(createMockConfig({ platform: 'StandaloneWindows64' }));
registry.registerRunner(createMockConfig({ platform: 'StandaloneLinux64' }));
registry.registerRunner(createMockConfig({ platform: 'StandaloneWindows64' }));
const windows = registry.listRunners({ platform: 'StandaloneWindows64' });
expect(windows).toHaveLength(2);
const linux = registry.listRunners({ platform: 'StandaloneLinux64' });
expect(linux).toHaveLength(1);
});
it('should filter runners by state', () => {
const id1 = registry.registerRunner(createMockConfig());
registry.registerRunner(createMockConfig());
registry.updateRunner(id1, { state: 'idle' });
// second runner remains in 'starting' state
const idle = registry.listRunners({ state: 'idle' });
expect(idle).toHaveLength(1);
expect(idle[0].id).toBe(id1);
});
it('should filter runners by Unity version', () => {
registry.registerRunner(createMockConfig({ unityVersion: '2022.3.0f1' }));
registry.registerRunner(createMockConfig({ unityVersion: '2023.1.0f1' }));
registry.registerRunner(createMockConfig({ unityVersion: '2022.3.0f1' }));
const v2022 = registry.listRunners({ unityVersion: '2022.3.0f1' });
expect(v2022).toHaveLength(2);
});
it('should find an available idle runner matching requirements', () => {
const id1 = registry.registerRunner(
createMockConfig({ unityVersion: '2022.3.0f1', platform: 'StandaloneWindows64' }),
);
registry.updateRunner(id1, { state: 'idle' });
const id2 = registry.registerRunner(
createMockConfig({ unityVersion: '2023.1.0f1', platform: 'StandaloneLinux64' }),
);
registry.updateRunner(id2, { state: 'idle' });
const found = registry.findAvailableRunner({
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
});
expect(found).toBeDefined();
expect(found!.id).toBe(id1);
});
it('should return undefined when no runner matches requirements', () => {
const id = registry.registerRunner(
createMockConfig({ unityVersion: '2022.3.0f1', platform: 'StandaloneWindows64' }),
);
registry.updateRunner(id, { state: 'idle' });
const found = registry.findAvailableRunner({
unityVersion: '2023.1.0f1',
platform: 'StandaloneLinux64',
});
expect(found).toBeUndefined();
});
it('should update runner status fields', () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle', memoryUsageMB: 2048 });
const runner = registry.getRunner(id);
expect(runner!.state).toBe('idle');
expect(runner!.memoryUsageMB).toBe(2048);
// ID should not be overridden by the update
expect(runner!.id).toBe(id);
});
it('should persist and load registry from disk', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.writeFileSync.mockImplementation(() => {});
mockFs.mkdirSync.mockImplementation(() => '' as any);
const id = persistenceRegistry.registerRunner(createMockConfig());
// Verify writeFileSync was called for persistence
expect(mockFs.writeFileSync).toHaveBeenCalled();
const writtenData = JSON.parse((mockFs.writeFileSync as jest.Mock).mock.calls[0][1] as string);
expect(writtenData.runners).toBeDefined();
expect(writtenData.runners[id]).toBeDefined();
});
it('should load runners from disk on loadFromDisk', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
const storedData = {
runners: {
'hr-restored': {
id: 'hr-restored',
state: 'idle',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 100,
jobsCompleted: 3,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 512,
},
},
configs: {
'hr-restored': createMockConfig(),
},
};
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify(storedData));
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(1);
expect(persistenceRegistry.getRunner('hr-restored')).toBeDefined();
});
it('should discard invalid runner entries when loading from disk', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
const storedData = {
runners: {
'hr-valid': {
id: 'hr-valid',
state: 'idle',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 100,
jobsCompleted: 3,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 512,
},
'hr-invalid': {
// Missing required fields like state, unityVersion
id: 'hr-invalid',
},
'hr-bad-state': {
id: 'hr-bad-state',
state: 'nonexistent-state',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 0,
jobsCompleted: 0,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 0,
},
},
configs: {
'hr-valid': createMockConfig(),
'hr-invalid': createMockConfig(),
'hr-bad-state': createMockConfig(),
},
};
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify(storedData));
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(1);
expect(persistenceRegistry.getRunner('hr-valid')).toBeDefined();
expect(persistenceRegistry.getRunner('hr-invalid')).toBeUndefined();
expect(persistenceRegistry.getRunner('hr-bad-state')).toBeUndefined();
});
it('should handle corrupt JSON persistence file gracefully', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue('{ invalid json !!!');
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(0);
expect(persistenceRegistry.size).toBe(0);
});
it('should handle persistence file with invalid top-level structure', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue('"just a string"');
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(0);
});
it('should handle persistence file with null runners', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue('{"runners": null, "configs": null}');
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(0);
});
it('should validate and repair invalid runners', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.writeFileSync.mockImplementation(() => {});
mockFs.mkdirSync.mockImplementation(() => '' as any);
// Register a valid runner first
const id = persistenceRegistry.registerRunner(createMockConfig());
persistenceRegistry.updateRunner(id, { state: 'idle' });
// Manually corrupt the runner's state by setting an invalid state
// (we access via the public API -- updateRunner with a cast)
persistenceRegistry.updateRunner(id, { state: 'invalid-state' as any });
const repaired = persistenceRegistry.validateAndRepair();
expect(repaired).toBe(1);
const runner = persistenceRegistry.getRunner(id);
expect(runner!.state).toBe('unhealthy');
});
it('should not discard configs for valid runners when loading from disk', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
const storedData = {
runners: {
'hr-valid': {
id: 'hr-valid',
state: 'idle',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 100,
jobsCompleted: 3,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 512,
},
},
configs: {
'hr-valid': createMockConfig(),
},
};
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify(storedData));
persistenceRegistry.loadFromDisk();
expect(persistenceRegistry.getConfig('hr-valid')).toBeDefined();
});
});
// --- Health Monitor Tests ---
describe('HotRunnerHealthMonitor', () => {
let monitor: HotRunnerHealthMonitor;
let registry: HotRunnerRegistry;
let transports: Map<string, HotRunnerTransport>;
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
monitor = new HotRunnerHealthMonitor();
registry = new HotRunnerRegistry();
transports = new Map();
});
afterEach(() => {
monitor.stopMonitoring();
jest.useRealTimers();
});
it('should start and stop monitoring', () => {
monitor.startMonitoring(registry, 30, transports);
expect(monitor.isMonitoring).toBe(true);
monitor.stopMonitoring();
expect(monitor.isMonitoring).toBe(false);
});
it('should report healthy when transport health check passes', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport();
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
const healthy = await monitor.checkHealth(id);
expect(healthy).toBe(true);
expect(transport.healthCheck).toHaveBeenCalled();
});
it('should mark runner as unhealthy when health check fails', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
healthCheck: jest.fn().mockResolvedValue(false),
});
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
const healthy = await monitor.checkHealth(id);
expect(healthy).toBe(false);
const runner = registry.getRunner(id);
expect(runner!.state).toBe('unhealthy');
});
it('should mark runner as unhealthy when health check throws', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
healthCheck: jest.fn().mockRejectedValue(new Error('Connection refused')),
});
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
const healthy = await monitor.checkHealth(id);
expect(healthy).toBe(false);
});
it('should recycle unhealthy runner and remove from registry', async () => {
const id = registry.registerRunner(createMockConfig());
const transport = createMockTransport();
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
await monitor.recycleUnhealthyRunner(id);
expect(registry.getRunner(id)).toBeUndefined();
expect(transport.disconnect).toHaveBeenCalled();
expect(transports.has(id)).toBe(false);
});
it('should recycle idle runner when max idle time exceeded', async () => {
const id = registry.registerRunner(createMockConfig({ maxIdleTime: 60 }));
// Set lastHealthCheck to 120 seconds ago
const oldDate = new Date(Date.now() - 120 * 1000).toISOString();
registry.updateRunner(id, { state: 'idle', lastHealthCheck: oldDate });
const transport = createMockTransport();
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
await monitor.recycleIdleRunner(id, 60);
expect(registry.getRunner(id)).toBeUndefined();
});
it('should not recycle idle runner when within max idle time', async () => {
const id = registry.registerRunner(createMockConfig({ maxIdleTime: 3600 }));
registry.updateRunner(id, {
state: 'idle',
lastHealthCheck: new Date().toISOString(),
});
const transport = createMockTransport();
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
await monitor.recycleIdleRunner(id, 3600);
// Runner should still exist
expect(registry.getRunner(id)).toBeDefined();
});
it('should return false when no transport exists for runner', async () => {
const id = registry.registerRunner(createMockConfig());
// Do not set any transport for this runner
monitor.startMonitoring(registry, 30, transports);
const healthy = await monitor.checkHealth(id);
expect(healthy).toBe(false);
});
});
// --- Dispatcher Tests ---
describe('HotRunnerDispatcher', () => {
let registry: HotRunnerRegistry;
let transports: Map<string, HotRunnerTransport>;
let dispatcher: HotRunnerDispatcher;
beforeEach(() => {
jest.clearAllMocks();
registry = new HotRunnerRegistry();
transports = new Map();
dispatcher = new HotRunnerDispatcher(transports);
});
it('should dispatch a job to an available runner', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport();
transports.set(id, transport);
const request = createMockJobRequest();
const result = await dispatcher.dispatchJob(request, registry, '2022.3.0f1');
expect(result.success).toBe(true);
expect(result.exitCode).toBe(0);
expect(transport.sendJob).toHaveBeenCalledWith(request);
});
it('should mark runner as busy during job execution', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const statesDuringJob: string[] = [];
const transport = createMockTransport({
sendJob: jest.fn().mockImplementation(async () => {
const runner = registry.getRunner(id);
if (runner) statesDuringJob.push(runner.state);
return {
jobId: 'job-001',
success: true,
exitCode: 0,
duration: 1000,
output: 'ok',
};
}),
});
transports.set(id, transport);
await dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1');
expect(statesDuringJob).toContain('busy');
// After completion, should be idle again
const runner = registry.getRunner(id);
expect(runner!.state).toBe('idle');
});
it('should increment jobsCompleted after successful dispatch', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle', jobsCompleted: 5 });
const transport = createMockTransport();
transports.set(id, transport);
await dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1');
const runner = registry.getRunner(id);
expect(runner!.jobsCompleted).toBe(6);
});
it('should throw when no runner is available and wait times out', async () => {
// No runners registered at all
const request = createMockJobRequest({ timeout: 100 });
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/Timed out waiting/);
});
it('should throw when runner has no transport', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
// No transport set for this runner
const request = createMockJobRequest();
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/No transport available/);
});
it('should handle job failure and return runner to idle', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
sendJob: jest.fn().mockRejectedValue(new Error('Unity crashed')),
});
transports.set(id, transport);
await expect(dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1')).rejects.toThrow(
'Unity crashed',
);
// Runner should be back to idle despite failure
const runner = registry.getRunner(id);
expect(runner!.state).toBe('idle');
});
it('should handle job timeout', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
sendJob: jest.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 60000)), // never resolves within timeout
),
});
transports.set(id, transport);
const request = createMockJobRequest({ timeout: 50 });
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/timed out/);
});
it('should disconnect transport on job timeout', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
sendJob: jest.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 60000)), // never resolves within timeout
),
});
transports.set(id, transport);
const request = createMockJobRequest({ timeout: 50 });
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/timed out/);
// Transport should have been disconnected to clean up orphaned connection
expect(transport.disconnect).toHaveBeenCalled();
});
it('should call output callback with job output', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport();
transports.set(id, transport);
const outputCallback = jest.fn();
await dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1', outputCallback);
expect(outputCallback).toHaveBeenCalledWith('Build succeeded');
});
it('should wait for runner to become available', async () => {
const id = registry.registerRunner(createMockConfig());
// Runner starts in 'starting' state, not idle
const transport = createMockTransport();
transports.set(id, transport);
// Simulate runner becoming idle after a short delay
setTimeout(() => {
registry.updateRunner(id, { state: 'idle' });
}, 50);
const request = createMockJobRequest({ timeout: 5000 });
const result = await dispatcher.dispatchJob(request, registry, '2022.3.0f1');
expect(result.success).toBe(true);
});
});
// --- Service Integration Tests ---
describe('HotRunnerService', () => {
let service: HotRunnerService;
beforeEach(() => {
jest.clearAllMocks();
mockFs.existsSync.mockReturnValue(false);
service = new HotRunnerService();
});
afterEach(async () => {
await service.shutdown();
});
it('should initialize and shut down cleanly', async () => {
const config = createMockConfig();
await service.initialize(config);
const status = service.getStatus();
expect(status).toEqual([]);
await service.shutdown();
});
it('should register a runner with transport', async () => {
await service.initialize(createMockConfig());
const transport = createMockTransport();
const id = service.registerRunner(createMockConfig(), transport);
expect(id).toMatch(/^hr-/);
expect(service.getStatus()).toHaveLength(1);
});
it('should disconnect all transports on shutdown', async () => {
await service.initialize(createMockConfig());
const transport1 = createMockTransport();
const transport2 = createMockTransport();
service.registerRunner(createMockConfig(), transport1);
service.registerRunner(createMockConfig(), transport2);
await service.shutdown();
expect(transport1.disconnect).toHaveBeenCalled();
expect(transport2.disconnect).toHaveBeenCalled();
});
it('should expose the underlying registry', async () => {
await service.initialize(createMockConfig());
const registry = service.getRegistry();
expect(registry).toBeInstanceOf(HotRunnerRegistry);
});
});

View File

@@ -0,0 +1,11 @@
export { HotRunnerService } from './hot-runner-service';
export { HotRunnerRegistry } from './hot-runner-registry';
export { HotRunnerHealthMonitor } from './hot-runner-health-monitor';
export { HotRunnerDispatcher } from './hot-runner-dispatcher';
export type {
HotRunnerConfig,
HotRunnerStatus,
HotRunnerJobRequest,
HotRunnerJobResult,
HotRunnerTransport,
} from './hot-runner-types';

View File

@@ -1,638 +0,0 @@
import { execSync, execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { BuildReliabilityService } from './build-reliability-service';
// Mock dependencies
jest.mock('node:child_process');
jest.mock('node:fs');
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
}));
const mockExecSync = execSync as jest.MockedFunction<typeof execSync>;
const mockExecFileSync = execFileSync as jest.MockedFunction<typeof execFileSync>;
const mockFs = fs as jest.Mocked<typeof fs>;
describe('BuildReliabilityService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// =========================================================================
// checkGitIntegrity
// =========================================================================
describe('checkGitIntegrity', () => {
it('should return true when fsck succeeds with clean output', () => {
mockExecSync.mockReturnValue('');
const result = BuildReliabilityService.checkGitIntegrity('/repo');
expect(result).toBe(true);
expect(mockExecSync).toHaveBeenCalledWith(
'git -C "/repo" fsck --no-dangling',
expect.objectContaining({ encoding: 'utf8' }),
);
});
it('should return false when fsck output contains corruption indicators', () => {
mockExecSync.mockReturnValue('broken link from tree abc123');
const result = BuildReliabilityService.checkGitIntegrity('/repo');
expect(result).toBe(false);
});
it('should return false when fsck output contains missing objects', () => {
mockExecSync.mockReturnValue('missing blob abc123');
const result = BuildReliabilityService.checkGitIntegrity('/repo');
expect(result).toBe(false);
});
it('should return false when execSync throws (non-zero exit code)', () => {
mockExecSync.mockImplementation(() => {
const error: any = new Error('fsck failed');
error.stderr = Buffer.from('error: bad object HEAD');
throw error;
});
const result = BuildReliabilityService.checkGitIntegrity('/repo');
expect(result).toBe(false);
});
it('should use current directory when no repoPath provided', () => {
mockExecSync.mockReturnValue('');
BuildReliabilityService.checkGitIntegrity();
expect(mockExecSync).toHaveBeenCalledWith('git -C "." fsck --no-dangling', expect.anything());
});
});
// =========================================================================
// cleanStaleLockFiles
// =========================================================================
describe('cleanStaleLockFiles', () => {
it('should return 0 when .git directory does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
const result = BuildReliabilityService.cleanStaleLockFiles('/repo');
expect(result).toBe(0);
});
it('should remove lock files older than 10 minutes', () => {
const now = Date.now();
const oldTime = now - 15 * 60 * 1000; // 15 minutes ago
mockFs.existsSync.mockReturnValue(true);
mockFs.statSync.mockImplementation((filePath: any) => {
if (filePath === path.join('/repo', '.git')) {
return { isDirectory: () => true } as fs.Stats;
}
return { mtimeMs: oldTime } as fs.Stats;
});
mockFs.readdirSync.mockImplementation((dir: any) => {
if (dir === path.join('/repo', '.git')) {
return [
{ name: 'index.lock', isDirectory: () => false },
{ name: 'HEAD.lock', isDirectory: () => false },
] as any;
}
return [];
});
mockFs.unlinkSync.mockReturnValue(undefined);
const result = BuildReliabilityService.cleanStaleLockFiles('/repo');
expect(result).toBe(2);
expect(mockFs.unlinkSync).toHaveBeenCalledTimes(2);
});
it('should NOT remove lock files younger than 10 minutes', () => {
const now = Date.now();
const recentTime = now - 2 * 60 * 1000; // 2 minutes ago
mockFs.existsSync.mockReturnValue(true);
mockFs.statSync.mockImplementation((filePath: any) => {
if (filePath === path.join('/repo', '.git')) {
return { isDirectory: () => true } as fs.Stats;
}
return { mtimeMs: recentTime } as fs.Stats;
});
mockFs.readdirSync.mockImplementation((dir: any) => {
if (dir === path.join('/repo', '.git')) {
return [{ name: 'index.lock', isDirectory: () => false }] as any;
}
return [];
});
const result = BuildReliabilityService.cleanStaleLockFiles('/repo');
expect(result).toBe(0);
expect(mockFs.unlinkSync).not.toHaveBeenCalled();
});
it('should recursively scan refs directory for lock files', () => {
const now = Date.now();
const oldTime = now - 15 * 60 * 1000;
mockFs.existsSync.mockReturnValue(true);
mockFs.statSync.mockImplementation((filePath: any) => {
if (filePath === path.join('/repo', '.git')) {
return { isDirectory: () => true } as fs.Stats;
}
return { mtimeMs: oldTime } as fs.Stats;
});
mockFs.readdirSync.mockImplementation((dir: any) => {
const gitDir = path.join('/repo', '.git');
if (dir === gitDir) {
return [{ name: 'refs', isDirectory: () => true }] as any;
}
if (dir === path.join(gitDir, 'refs')) {
return [{ name: 'heads', isDirectory: () => true }] as any;
}
if (dir === path.join(gitDir, 'refs', 'heads')) {
return [{ name: 'main.lock', isDirectory: () => false }] as any;
}
return [];
});
mockFs.unlinkSync.mockReturnValue(undefined);
const result = BuildReliabilityService.cleanStaleLockFiles('/repo');
expect(result).toBe(1);
});
});
// =========================================================================
// validateSubmoduleBackingStores
// =========================================================================
describe('validateSubmoduleBackingStores', () => {
it('should return empty array when .gitmodules does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
const result = BuildReliabilityService.validateSubmoduleBackingStores('/repo');
expect(result).toEqual([]);
});
it('should detect broken backing store for submodule', () => {
mockFs.existsSync.mockImplementation((p: any) => {
if (p === path.join('/repo', '.gitmodules')) return true;
if (p === path.join('/repo', 'lib/sub', '.git')) return true;
// Backing store does not exist
return false;
});
mockFs.readFileSync.mockImplementation((p: any) => {
if (p === path.join('/repo', '.gitmodules')) {
return '[submodule "sub"]\n\tpath = lib/sub\n\turl = https://example.com/sub.git';
}
if (p === path.join('/repo', 'lib/sub', '.git')) {
return 'gitdir: ../../.git/modules/lib/sub';
}
return '';
});
mockFs.statSync.mockReturnValue({ isFile: () => true } as fs.Stats);
const result = BuildReliabilityService.validateSubmoduleBackingStores('/repo');
expect(result).toContain('lib/sub');
});
it('should return empty array when all submodule backing stores are valid', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockImplementation((p: any) => {
if (p === path.join('/repo', '.gitmodules')) {
return '[submodule "sub"]\n\tpath = lib/sub\n\turl = https://example.com/sub.git';
}
if (p === path.join('/repo', 'lib/sub', '.git')) {
return 'gitdir: ../../.git/modules/lib/sub';
}
return '';
});
mockFs.statSync.mockReturnValue({ isFile: () => true } as fs.Stats);
const result = BuildReliabilityService.validateSubmoduleBackingStores('/repo');
expect(result).toEqual([]);
});
});
// =========================================================================
// recoverCorruptedRepo
// =========================================================================
describe('recoverCorruptedRepo', () => {
it('should orchestrate fsck cleanup and re-fetch, returning true on success', () => {
// cleanStaleLockFiles: no .git dir
mockFs.existsSync.mockReturnValue(false);
mockFs.statSync.mockReturnValue({ isDirectory: () => false } as fs.Stats);
// fetch succeeds, then fsck succeeds
mockExecSync.mockReturnValue('');
const result = BuildReliabilityService.recoverCorruptedRepo('/repo');
expect(result).toBe(true);
// Should have called fetch
expect(mockExecSync).toHaveBeenCalledWith('git -C "/repo" fetch --all', expect.anything());
});
it('should return false when recovery fails to restore integrity', () => {
mockFs.existsSync.mockReturnValue(false);
mockFs.statSync.mockReturnValue({ isDirectory: () => false } as fs.Stats);
// fetch succeeds, but fsck fails
mockExecSync.mockImplementation((cmd: any) => {
if (typeof cmd === 'string' && cmd.includes('fetch')) return '';
if (typeof cmd === 'string' && cmd.includes('fsck')) {
return 'missing blob abc123';
}
return '';
});
const result = BuildReliabilityService.recoverCorruptedRepo('/repo');
expect(result).toBe(false);
});
it('should continue recovery even when fetch fails', () => {
mockFs.existsSync.mockReturnValue(false);
mockFs.statSync.mockReturnValue({ isDirectory: () => false } as fs.Stats);
let callCount = 0;
mockExecSync.mockImplementation((cmd: any) => {
callCount++;
if (typeof cmd === 'string' && cmd.includes('fetch')) {
throw new Error('network error');
}
// fsck call
return '';
});
const result = BuildReliabilityService.recoverCorruptedRepo('/repo');
// Should still attempt fsck after failed fetch
expect(callCount).toBeGreaterThanOrEqual(2);
});
});
// =========================================================================
// cleanReservedFilenames
// =========================================================================
describe('cleanReservedFilenames', () => {
it('should return empty array when Assets directory does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
const result = BuildReliabilityService.cleanReservedFilenames('/project');
expect(result).toEqual([]);
});
it('should remove files with reserved names (con, prn, aux, nul)', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readdirSync.mockImplementation((dir: any) => {
if (dir === path.join('/project', 'Assets')) {
return [
{ name: 'con.txt', isDirectory: () => false },
{ name: 'PRN.meta', isDirectory: () => false },
{ name: 'aux.shader', isDirectory: () => false },
{ name: 'nul.png', isDirectory: () => false },
{ name: 'valid-file.cs', isDirectory: () => false },
] as any;
}
return [];
});
mockFs.unlinkSync.mockReturnValue(undefined);
const result = BuildReliabilityService.cleanReservedFilenames('/project');
expect(result).toHaveLength(4);
expect(mockFs.unlinkSync).toHaveBeenCalledTimes(4);
});
it('should remove directories with reserved names', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readdirSync.mockImplementation((dir: any) => {
if (dir === path.join('/project', 'Assets')) {
return [{ name: 'com1', isDirectory: () => true }] as any;
}
return [];
});
mockFs.rmSync.mockReturnValue(undefined);
const result = BuildReliabilityService.cleanReservedFilenames('/project');
expect(result).toHaveLength(1);
expect(mockFs.rmSync).toHaveBeenCalledWith(path.join('/project', 'Assets', 'com1'), {
recursive: true,
force: true,
});
});
it('should detect COM1 through COM9 and LPT1 through LPT9', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readdirSync.mockImplementation((dir: any) => {
if (dir === path.join('/project', 'Assets')) {
return [
{ name: 'com1.txt', isDirectory: () => false },
{ name: 'COM9.meta', isDirectory: () => false },
{ name: 'lpt1.dat', isDirectory: () => false },
{ name: 'LPT9.log', isDirectory: () => false },
] as any;
}
return [];
});
mockFs.unlinkSync.mockReturnValue(undefined);
const result = BuildReliabilityService.cleanReservedFilenames('/project');
expect(result).toHaveLength(4);
});
it('should not remove files that merely contain reserved names as substrings', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readdirSync.mockImplementation((dir: any) => {
if (dir === path.join('/project', 'Assets')) {
return [
{ name: 'controller.cs', isDirectory: () => false },
{ name: 'printer-utils.cs', isDirectory: () => false },
{ name: 'auxiliary.shader', isDirectory: () => false },
] as any;
}
return [];
});
const result = BuildReliabilityService.cleanReservedFilenames('/project');
expect(result).toHaveLength(0);
});
});
// =========================================================================
// getAvailableSpaceMB
// =========================================================================
describe('getAvailableSpaceMB', () => {
it('should return -1 when the check fails', () => {
mockExecFileSync.mockImplementation(() => {
throw new Error('Command failed');
});
const result = BuildReliabilityService.getAvailableSpaceMB('/some/path');
expect(result).toBe(-1);
});
it('should parse wmic output on Windows', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
// 10 GB in bytes
mockExecFileSync.mockReturnValue('\r\nFreeSpace=10737418240\r\n' as any);
const result = BuildReliabilityService.getAvailableSpaceMB('C:\\builds');
// 10737418240 / (1024 * 1024) = 10240 MB
expect(result).toBeCloseTo(10240, 0);
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should parse df output on Unix', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'linux' });
mockExecFileSync.mockReturnValue(' Avail\n 5120M\n' as any);
const result = BuildReliabilityService.getAvailableSpaceMB('/builds');
expect(result).toBe(5120);
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
});
// =========================================================================
// getDirectorySizeMB
// =========================================================================
describe('getDirectorySizeMB', () => {
it('should return file size for a single file', () => {
// 5 MB in bytes
mockFs.statSync.mockReturnValue({ isDirectory: () => false, size: 5 * 1024 * 1024 } as any);
const result = BuildReliabilityService.getDirectorySizeMB('/path/to/file.zip');
expect(result).toBeCloseTo(5, 0);
});
it('should return total size for a directory tree', () => {
const subDir = path.join('/build', 'sub');
mockFs.statSync.mockImplementation((p: any) => {
const pathStr = typeof p === 'string' ? p : p.toString();
if (pathStr === '/build' || pathStr === subDir) {
return { isDirectory: () => true, size: 0 } as any;
}
return { isDirectory: () => false, size: 1024 * 1024 } as any; // 1 MB each
});
mockFs.readdirSync.mockImplementation((dirPath: any, _options?: any) => {
const dirStr = typeof dirPath === 'string' ? dirPath : dirPath.toString();
if (dirStr === '/build') {
return [
{ name: 'file1.bin', isDirectory: () => false },
{ name: 'sub', isDirectory: () => true },
] as any;
}
if (dirStr === subDir) {
return [{ name: 'file2.bin', isDirectory: () => false }] as any;
}
return [] as any;
});
const result = BuildReliabilityService.getDirectorySizeMB('/build');
expect(result).toBeCloseTo(2, 0); // 2 files * 1 MB each
});
it('should return -1 when calculation fails', () => {
mockFs.statSync.mockImplementation(() => {
throw new Error('Access denied');
});
const result = BuildReliabilityService.getDirectorySizeMB('/inaccessible');
expect(result).toBe(-1);
});
});
// =========================================================================
// archiveBuildOutput
// =========================================================================
describe('archiveBuildOutput', () => {
it('should skip archiving when source path does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
BuildReliabilityService.archiveBuildOutput('/builds/output', '/archives');
expect(mockExecSync).not.toHaveBeenCalled();
});
it('should create archive directory and tar.gz output', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.mkdirSync.mockReturnValue(undefined as any);
mockExecSync.mockReturnValue('');
// Make disk space check return unknown so we proceed
mockExecFileSync.mockImplementation(() => {
throw new Error('Command not found');
});
mockFs.statSync.mockImplementation(() => {
throw new Error('Not mocked');
});
BuildReliabilityService.archiveBuildOutput('/builds/output', '/archives');
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/archives', { recursive: true });
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining('tar -czf'), expect.anything());
});
it('should skip archival when insufficient disk space', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.mkdirSync.mockReturnValue(undefined as any);
// Source is 1000 MB
mockFs.statSync.mockImplementation((p: any) => {
const pathStr = typeof p === 'string' ? p : p.toString();
if (pathStr.endsWith('big-file.bin')) {
return { isDirectory: () => false, size: 1000 * 1024 * 1024 } as any;
}
return { isDirectory: () => true, size: 0 } as any;
});
mockFs.readdirSync.mockImplementation(() => {
return [{ name: 'big-file.bin', isDirectory: () => false }] as any;
});
// Only 500 MB available
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'linux' });
mockExecFileSync.mockReturnValue(' Avail\n 500M\n' as any);
BuildReliabilityService.archiveBuildOutput('/builds/output', '/archives');
// Should NOT have attempted the tar command
expect(mockExecSync).not.toHaveBeenCalledWith(expect.stringContaining('tar'), expect.anything());
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should clean up partial archive on tar failure', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.mkdirSync.mockReturnValue(undefined as any);
mockFs.unlinkSync.mockReturnValue(undefined);
// Make disk space check return unknown so we proceed
mockExecFileSync.mockImplementation(() => {
throw new Error('Command not found');
});
mockFs.statSync.mockImplementation(() => {
throw new Error('Not mocked');
});
// tar command fails
mockExecSync.mockImplementation(() => {
const error: any = new Error('tar failed');
error.stderr = Buffer.from('No space left on device');
throw error;
});
BuildReliabilityService.archiveBuildOutput('/builds/output', '/archives');
// Should have attempted to clean up the partial archive
// (existsSync returns true for the partial file)
expect(mockFs.unlinkSync).toHaveBeenCalled();
});
it('should proceed with warning when disk space check fails', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.mkdirSync.mockReturnValue(undefined as any);
mockExecSync.mockReturnValue('');
// Disk space check fails
mockExecFileSync.mockImplementation(() => {
throw new Error('Command not found');
});
// Directory size check also fails
mockFs.statSync.mockImplementation(() => {
throw new Error('Not mocked');
});
BuildReliabilityService.archiveBuildOutput('/builds/output', '/archives');
// Should still proceed with tar
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining('tar -czf'), expect.anything());
});
});
// =========================================================================
// enforceRetention
// =========================================================================
describe('enforceRetention', () => {
it('should return 0 when archive path does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
const result = BuildReliabilityService.enforceRetention('/archive', 30);
expect(result).toBe(0);
});
it('should remove archives older than retention period', () => {
const now = Date.now();
const oldTime = now - 45 * 24 * 60 * 60 * 1000; // 45 days ago
const recentTime = now - 5 * 24 * 60 * 60 * 1000; // 5 days ago
mockFs.existsSync.mockReturnValue(true);
mockFs.readdirSync.mockReturnValue([
{ name: 'build-old.tar.gz', isDirectory: () => false },
{ name: 'build-recent.tar.gz', isDirectory: () => false },
] as any);
mockFs.statSync.mockImplementation((p: any) => {
if ((p as string).includes('old')) {
return { mtimeMs: oldTime } as fs.Stats;
}
return { mtimeMs: recentTime } as fs.Stats;
});
mockFs.unlinkSync.mockReturnValue(undefined);
const result = BuildReliabilityService.enforceRetention('/archive', 30);
expect(result).toBe(1);
expect(mockFs.unlinkSync).toHaveBeenCalledTimes(1);
});
it('should keep all archives within retention period', () => {
const now = Date.now();
const recentTime = now - 5 * 24 * 60 * 60 * 1000;
mockFs.existsSync.mockReturnValue(true);
mockFs.readdirSync.mockReturnValue([
{ name: 'build-1.tar.gz', isDirectory: () => false },
{ name: 'build-2.tar.gz', isDirectory: () => false },
] as any);
mockFs.statSync.mockReturnValue({ mtimeMs: recentTime } as fs.Stats);
const result = BuildReliabilityService.enforceRetention('/archive', 30);
expect(result).toBe(0);
});
});
// =========================================================================
// configureGitEnvironment
// =========================================================================
describe('configureGitEnvironment', () => {
it('should set GIT_TERMINAL_PROMPT=0 in process.env', () => {
mockExecSync.mockReturnValue('');
BuildReliabilityService.configureGitEnvironment();
expect(process.env.GIT_TERMINAL_PROMPT).toBe('0');
});
it('should configure http.postBuffer via git config', () => {
mockExecSync.mockReturnValue('');
BuildReliabilityService.configureGitEnvironment();
expect(mockExecSync).toHaveBeenCalledWith('git config --global http.postBuffer 524288000', expect.anything());
});
it('should configure core.longpaths via git config', () => {
mockExecSync.mockReturnValue('');
BuildReliabilityService.configureGitEnvironment();
expect(mockExecSync).toHaveBeenCalledWith('git config --global core.longpaths true', expect.anything());
});
it('should warn but not throw when git config commands fail', () => {
const core = require('@actions/core');
mockExecSync.mockImplementation(() => {
throw new Error('git config failed');
});
// Should not throw
expect(() => BuildReliabilityService.configureGitEnvironment()).not.toThrow();
expect(core.warning).toHaveBeenCalled();
});
});
});

View File

@@ -1,527 +0,0 @@
import { execSync, execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import * as core from '@actions/core';
/**
* Build reliability features for hardening CI pipelines.
* Provides git integrity checks, stale lock cleanup, submodule validation,
* reserved filename removal, build archival, and git environment configuration.
* All features are opt-in and fail gracefully (warnings only).
*/
export class BuildReliabilityService {
// Windows reserved device names that cause Unity asset importer infinite loops
private static readonly RESERVED_NAMES = new Set([
'con',
'prn',
'aux',
'nul',
'com1',
'com2',
'com3',
'com4',
'com5',
'com6',
'com7',
'com8',
'com9',
'lpt1',
'lpt2',
'lpt3',
'lpt4',
'lpt5',
'lpt6',
'lpt7',
'lpt8',
'lpt9',
]);
// Lock files to look for in the .git directory
private static readonly LOCK_FILE_NAMES = new Set(['index.lock', 'shallow.lock', 'config.lock', 'HEAD.lock']);
// Maximum age in milliseconds before a lock file is considered stale (10 minutes)
private static readonly LOCK_FILE_MAX_AGE_MS = 10 * 60 * 1000;
/**
* Run git fsck to check repository integrity.
* Returns true if the repo is healthy, false if corruption detected.
*/
static checkGitIntegrity(repoPath: string = '.'): boolean {
core.info(`[Reliability] Checking git integrity in ${repoPath}`);
try {
const output = execSync(`git -C "${repoPath}" fsck --no-dangling`, {
encoding: 'utf8',
timeout: 120_000,
stdio: ['pipe', 'pipe', 'pipe'],
});
// Parse output for corruption indicators
const corruptionPatterns = [
/broken link/i,
/missing (blob|tree|commit|tag)/i,
/dangling/i,
/corrupt/i,
/error in /i,
];
for (const pattern of corruptionPatterns) {
if (pattern.test(output)) {
core.warning(`[Reliability] Git integrity check found issues: ${output.trim()}`);
return false;
}
}
core.info('[Reliability] Git integrity check passed');
return true;
} catch (error: any) {
// execSync throws on non-zero exit code
const stderr = error.stderr?.toString() ?? error.message;
core.warning(`[Reliability] Git integrity check failed: ${stderr}`);
return false;
}
}
/**
* Remove stale .lock files from the .git directory.
* Only removes lock files older than 10 minutes to avoid interfering with active operations.
* Returns the number of lock files removed.
*/
static cleanStaleLockFiles(repoPath: string = '.'): number {
const gitDir = path.join(repoPath, '.git');
if (!fs.existsSync(gitDir) || !fs.statSync(gitDir).isDirectory()) {
return 0;
}
core.info(`[Reliability] Scanning for stale lock files in ${gitDir}`);
const now = Date.now();
let removed = 0;
const cleanDirectory = (directory: string): void => {
if (!fs.existsSync(directory)) return;
try {
const entries = fs.readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
cleanDirectory(fullPath);
} else if (entry.name.endsWith('.lock')) {
// Check if it is a known lock file location OR under refs/
const relativePath = path.relative(gitDir, fullPath);
const isKnownLock = BuildReliabilityService.LOCK_FILE_NAMES.has(entry.name);
const isRefsLock = relativePath.startsWith('refs' + path.sep);
if (isKnownLock || isRefsLock) {
try {
const stat = fs.statSync(fullPath);
const ageMs = now - stat.mtimeMs;
if (ageMs > BuildReliabilityService.LOCK_FILE_MAX_AGE_MS) {
fs.unlinkSync(fullPath);
removed++;
core.info(
`[Reliability] Removed stale lock file (age: ${Math.round(ageMs / 1000)}s): ${relativePath}`,
);
} else {
core.info(
`[Reliability] Lock file is recent (age: ${Math.round(ageMs / 1000)}s), skipping: ${relativePath}`,
);
}
} catch {
core.warning(`[Reliability] Could not remove lock file: ${fullPath}`);
}
}
}
}
} catch {
// Directory not accessible
}
};
cleanDirectory(gitDir);
if (removed > 0) {
core.info(`[Reliability] Cleaned ${removed} stale lock file(s)`);
} else {
core.info('[Reliability] No stale lock files found');
}
return removed;
}
/**
* Validate that submodule .git files point to existing backing stores
* under .git/modules/. Returns list of submodule paths with broken backing stores.
*/
static validateSubmoduleBackingStores(repoPath: string = '.'): string[] {
const broken: string[] = [];
const gitmodulesPath = path.join(repoPath, '.gitmodules');
if (!fs.existsSync(gitmodulesPath)) {
core.info('[Reliability] No .gitmodules found, skipping submodule validation');
return broken;
}
core.info(`[Reliability] Validating submodule backing stores in ${repoPath}`);
try {
const content = fs.readFileSync(gitmodulesPath, 'utf8');
const pathMatches = content.matchAll(/path\s*=\s*(.+)/g);
for (const match of pathMatches) {
const submodulePath = match[1].trim();
const gitFile = path.join(repoPath, submodulePath, '.git');
if (!fs.existsSync(gitFile)) {
// Submodule not initialized -- not necessarily broken
continue;
}
try {
const stat = fs.statSync(gitFile);
if (stat.isFile()) {
// .git is a file -- should contain "gitdir: <path>"
const gitFileContent = fs.readFileSync(gitFile, 'utf8').trim();
const gitdirMatch = gitFileContent.match(/^gitdir:\s*(.+)$/);
if (gitdirMatch) {
const backingStore = path.resolve(path.join(repoPath, submodulePath), gitdirMatch[1]);
if (!fs.existsSync(backingStore)) {
broken.push(submodulePath);
core.warning(`[Reliability] Submodule ${submodulePath} has broken backing store: ${backingStore}`);
} else {
core.info(`[Reliability] Submodule ${submodulePath} backing store OK`);
}
} else {
broken.push(submodulePath);
core.warning(`[Reliability] Submodule ${submodulePath} .git file has invalid format`);
}
}
} catch {
// Can't read .git file
core.warning(`[Reliability] Could not read .git file for submodule: ${submodulePath}`);
}
}
} catch (error: any) {
core.warning(`[Reliability] Could not read .gitmodules: ${error.message}`);
}
if (broken.length > 0) {
core.warning(`[Reliability] ${broken.length} submodule(s) have broken backing stores`);
} else {
core.info('[Reliability] All submodule backing stores are valid');
}
return broken;
}
/**
* Orchestrate recovery of a corrupted repository.
* Sequence: fsck -> clean locks -> re-fetch -> retry fsck.
* Returns true if recovery succeeded.
*/
static recoverCorruptedRepo(repoPath: string = '.'): boolean {
core.warning(`[Reliability] Attempting automatic recovery for ${repoPath}`);
// Step 1: Clean stale lock files that may be preventing operations
const locksRemoved = BuildReliabilityService.cleanStaleLockFiles(repoPath);
if (locksRemoved > 0) {
core.info(`[Reliability] Recovery: cleaned ${locksRemoved} lock file(s)`);
}
// Step 2: Re-fetch to restore missing objects
try {
core.info('[Reliability] Recovery: re-fetching from remote');
execSync(`git -C "${repoPath}" fetch --all`, {
encoding: 'utf8',
timeout: 300_000,
stdio: ['pipe', 'pipe', 'pipe'],
});
core.info('[Reliability] Recovery: fetch completed');
} catch (error: any) {
core.warning(`[Reliability] Recovery: fetch failed: ${error.stderr?.toString() ?? error.message}`);
}
// Step 3: Retry fsck
const healthy = BuildReliabilityService.checkGitIntegrity(repoPath);
if (healthy) {
core.info('[Reliability] Recovery succeeded -- repository is healthy');
} else {
core.warning('[Reliability] Recovery failed -- repository still has integrity issues');
}
return healthy;
}
/**
* Scan a directory tree for files/directories with Windows reserved names.
* These names (con, prn, aux, nul, com1-9, lpt1-9) with any extension
* cause Unity asset importer infinite loops on Windows.
* Returns list of paths that were removed.
*/
static cleanReservedFilenames(projectPath: string): string[] {
const assetsPath = path.join(projectPath, 'Assets');
if (!fs.existsSync(assetsPath)) {
core.info(`[Reliability] No Assets directory found at ${assetsPath}, skipping reserved filename scan`);
return [];
}
core.info(`[Reliability] Scanning for reserved filenames in ${assetsPath}`);
const cleaned: string[] = [];
const scanDirectory = (directory: string): void => {
try {
const entries = fs.readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
const nameWithoutExtension = entry.name.split('.')[0].toLowerCase();
const fullPath = path.join(directory, entry.name);
if (BuildReliabilityService.RESERVED_NAMES.has(nameWithoutExtension)) {
try {
if (entry.isDirectory()) {
fs.rmSync(fullPath, { recursive: true, force: true });
} else {
fs.unlinkSync(fullPath);
}
cleaned.push(fullPath);
core.warning(`[Reliability] Removed reserved filename: ${fullPath}`);
} catch {
core.warning(`[Reliability] Could not remove reserved filename: ${fullPath}`);
}
} else if (entry.isDirectory()) {
scanDirectory(fullPath);
}
}
} catch {
// Directory not accessible
}
};
scanDirectory(assetsPath);
if (cleaned.length > 0) {
core.warning(`[Reliability] Cleaned ${cleaned.length} reserved filename(s)`);
} else {
core.info('[Reliability] No reserved filenames found');
}
return cleaned;
}
/**
* Get available disk space in megabytes for a given directory.
* Returns -1 if the check fails (unknown space).
*
* Cross-platform: uses wmic on Windows, df on Unix.
*/
static getAvailableSpaceMB(directoryPath: string): number {
try {
if (process.platform === 'win32') {
const drive = path.parse(directoryPath).root;
const driveLetter = drive.replace(/[:\\\/]/g, '');
const output = execFileSync(
'wmic',
['logicaldisk', 'where', `DeviceID='${driveLetter}:'`, 'get', 'FreeSpace', '/value'],
{ encoding: 'utf8', timeout: 10_000 },
);
const match = output.match(/FreeSpace=(\d+)/);
return match ? Number.parseInt(match[1], 10) / (1024 * 1024) : -1;
} else {
const output = execFileSync('df', ['-BM', '--output=avail', directoryPath], {
encoding: 'utf8',
timeout: 10_000,
});
const lines = output.trim().split('\n');
return Number.parseInt(lines[lines.length - 1], 10);
}
} catch {
return -1; // Unknown, caller should proceed with warning
}
}
/**
* Calculate the total size of a directory in megabytes.
* Returns -1 if the calculation fails.
*/
static getDirectorySizeMB(directoryPath: string): number {
try {
const stat = fs.statSync(directoryPath);
if (!stat.isDirectory()) {
return stat.size / (1024 * 1024);
}
let totalBytes = 0;
const walkDirectory = (dir: string): void => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDirectory(fullPath);
} else {
try {
totalBytes += fs.statSync(fullPath).size;
} catch {
// Skip inaccessible files
}
}
}
};
walkDirectory(directoryPath);
return totalBytes / (1024 * 1024);
} catch {
return -1;
}
}
/**
* Create a tar.gz archive of build output.
*
* Validates disk space before archiving. Skips archival with a warning
* if insufficient space is detected, preventing partial writes on full disks.
*/
static archiveBuildOutput(sourcePath: string, archivePath: string): void {
if (!fs.existsSync(sourcePath)) {
core.info(`[Reliability] No build output to archive at ${sourcePath}`);
return;
}
fs.mkdirSync(archivePath, { recursive: true });
// Check available disk space before archiving
const sourceSizeMB = BuildReliabilityService.getDirectorySizeMB(sourcePath);
const availableSpaceMB = BuildReliabilityService.getAvailableSpaceMB(archivePath);
if (sourceSizeMB >= 0 && availableSpaceMB >= 0) {
const neededMB = Math.ceil(sourceSizeMB * 1.1); // 10% safety margin
if (availableSpaceMB < neededMB) {
core.warning(
`[Reliability] Insufficient disk space for archive. ` +
`Need ~${neededMB}MB, available: ${Math.floor(availableSpaceMB)}MB. Skipping archive.`,
);
return;
}
core.info(
`[Reliability] Disk space check passed: need ~${neededMB}MB, available: ${Math.floor(availableSpaceMB)}MB`,
);
} else if (availableSpaceMB < 0) {
core.warning('[Reliability] Could not determine available disk space. Proceeding with archive cautiously.');
}
const timestamp = new Date().toISOString().replace(/[.:]/g, '-');
const archiveFile = path.join(archivePath, `build-${timestamp}.tar.gz`);
try {
execSync(`tar -czf "${archiveFile}" -C "${path.dirname(sourcePath)}" "${path.basename(sourcePath)}"`, {
encoding: 'utf8',
timeout: 600_000,
stdio: ['pipe', 'pipe', 'pipe'],
});
core.info(`[Reliability] Build output archived to ${archiveFile}`);
} catch (error: any) {
core.warning(`[Reliability] Failed to archive build output: ${error.stderr?.toString() ?? error.message}`);
// Clean up partial archive if it exists to avoid leaving corrupted files
try {
if (fs.existsSync(archiveFile)) {
fs.unlinkSync(archiveFile);
core.info(`[Reliability] Cleaned up partial archive: ${archiveFile}`);
}
} catch {
// Best-effort cleanup
}
}
}
/**
* Enforce retention policy -- delete archives older than the retention period.
* Returns the number of old archives removed.
*/
static enforceRetention(archivePath: string, retentionDays: number): number {
if (!fs.existsSync(archivePath)) {
return 0;
}
const now = Date.now();
const retentionMs = retentionDays * 24 * 60 * 60 * 1000;
let removed = 0;
try {
const entries = fs.readdirSync(archivePath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(archivePath, entry.name);
try {
const stat = fs.statSync(fullPath);
const ageMs = now - stat.mtimeMs;
if (ageMs > retentionMs) {
if (entry.isDirectory()) {
fs.rmSync(fullPath, { recursive: true, force: true });
} else {
fs.unlinkSync(fullPath);
}
removed++;
core.info(
`[Reliability] Removed old archive: ${entry.name} (age: ${Math.round(
ageMs / (24 * 60 * 60 * 1000),
)} days)`,
);
}
} catch {
core.warning(`[Reliability] Could not process archive entry: ${fullPath}`);
}
}
} catch {
core.warning(`[Reliability] Could not read archive directory: ${archivePath}`);
return 0;
}
if (removed > 0) {
core.info(
`[Reliability] Retention enforced: removed ${removed} old archive(s), retention: ${retentionDays} days`,
);
}
return removed;
}
/**
* Configure git environment variables for CI reliability.
* Sets GIT_TERMINAL_PROMPT=0, increases http.postBuffer, enables core.longpaths.
*/
static configureGitEnvironment(): void {
core.info('[Reliability] Configuring git environment for CI');
// Prevent git from prompting for credentials (hangs in CI)
process.env.GIT_TERMINAL_PROMPT = '0';
core.info('[Reliability] Set GIT_TERMINAL_PROMPT=0');
try {
// Increase http.postBuffer to 500MB for large pushes
execSync('git config --global http.postBuffer 524288000', {
encoding: 'utf8',
timeout: 10_000,
stdio: ['pipe', 'pipe', 'pipe'],
});
core.info('[Reliability] Set http.postBuffer=524288000 (500MB)');
} catch (error: any) {
core.warning(`[Reliability] Could not set http.postBuffer: ${error.message}`);
}
try {
// Enable long paths on Windows
execSync('git config --global core.longpaths true', {
encoding: 'utf8',
timeout: 10_000,
stdio: ['pipe', 'pipe', 'pipe'],
});
core.info('[Reliability] Set core.longpaths=true');
} catch (error: any) {
core.warning(`[Reliability] Could not set core.longpaths: ${error.message}`);
}
}
}

View File

@@ -1 +0,0 @@
export { BuildReliabilityService } from './build-reliability-service';