Compare commits

..

5 Commits

Author SHA1 Message Date
frostebite
e3c87cc1cd 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:07:26 +00:00
frostebite
1171b7e7ae ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:33:44 +00:00
frostebite
7db70a712f style: fix prettier formatting and eslint errors on test files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:23:05 +00:00
frostebite
f4451060a7 ci(orchestrator): add fast unit test gate to integrity workflow
Adds a fast-fail unit test step at the top of orchestrator-integrity,
right after yarn install and before any infrastructure setup (k3d,
LocalStack). Runs 113 mock-based orchestrator tests in ~5 seconds.

If serialization, path computation, log parsing, or provider loading
is broken, the workflow fails immediately instead of spending 30+
minutes setting up LocalStack and k3d clusters.

Tests included: orchestrator-guid, orchestrator-folders,
task-parameter-serializer, follow-log-stream-service,
runner-availability-service, provider-url-parser, provider-loader,
provider-git-manager, orchestrator-image, orchestrator-hooks,
orchestrator-github-checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:13:49 +00:00
frostebite
17a0ea3776 test(orchestrator): add unit tests for untested core services
Adds 64 new mock-based unit tests covering orchestrator services that
previously had zero test coverage:

- TaskParameterSerializer: env var format conversion, round-trip,
  uniqBy deduplication, blocked params, default secrets
- FollowLogStreamService: build output message parsing — end of
  transmission, build success/failure detection, error accumulation,
  Library rebuild detection
- OrchestratorNamespace (guid): GUID generation format, platform
  name normalization, nanoid uniqueness
- OrchestratorFolders: path computation for all folder getters,
  ToLinuxFolder conversion, repo URL generation, purge flag detection

All tests are pure mock-based and run without any external
infrastructure (no LocalStack, K8s, Docker, or AWS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:08:49 +00:00
14 changed files with 580 additions and 1859 deletions

View File

@@ -196,6 +196,15 @@ jobs:
fi
- run: yarn install --frozen-lockfile
# ==========================================
# FAST UNIT TESTS (no infra required, fast-fail gate)
# ==========================================
- name: Run orchestrator unit tests (fast, no infra)
timeout-minutes: 2
run: >-
yarn run test
--testPathPattern="orchestrator-guid|orchestrator-folders|task-parameter-serializer|follow-log-stream-service|runner-availability-service|provider-url-parser|provider-loader|provider-git-manager|orchestrator-image|orchestrator-hooks|orchestrator-github-checks"
--verbose --detectOpenHandles --forceExit --runInBand
# ==========================================
# K8S TESTS SECTION
# ==========================================
- name: Clean up disk space before K8s tests

View File

@@ -182,8 +182,8 @@ inputs:
required: false
default: ''
description:
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with
the keys image, secrets (name, value object array), command line string)'
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with the
keys image, secrets (name, value object array), command line string)'
awsStackName:
default: 'game-ci'
required: false
@@ -279,30 +279,6 @@ 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)'
required: false
default: 'false'
gitAutoRecover:
description: 'Attempt automatic recovery if git corruption is detected'
required: false
default: 'false'
cleanReservedFilenames:
description: 'Remove Windows reserved filenames that cause Unity import loops'
required: false
default: 'false'
buildArchiveEnabled:
description: 'Archive build output after successful build'
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'
required: false
default: '30'
outputs:
volume:

587
dist/index.js generated vendored
View File

@@ -38,7 +38,6 @@ const model_1 = __nccwpck_require__(41359);
const cli_1 = __nccwpck_require__(55651);
const mac_builder_1 = __importDefault(__nccwpck_require__(39364));
const platform_setup_1 = __importDefault(__nccwpck_require__(64423));
const reliability_1 = __nccwpck_require__(9842);
async function runMain() {
try {
if (cli_1.Cli.InitCliMode()) {
@@ -47,32 +46,9 @@ async function runMain() {
}
model_1.Action.checkCompatibility();
model_1.Cache.verify();
// Always configure git environment for CI reliability
reliability_1.BuildReliabilityService.configureGitEnvironment();
const { workspace, actionFolder } = model_1.Action;
const buildParameters = await model_1.BuildParameters.create();
const baseImage = new model_1.ImageTag(buildParameters);
// Pre-build reliability checks
if (buildParameters.gitIntegrityCheck) {
core.info('Running git integrity checks...');
const isHealthy = reliability_1.BuildReliabilityService.checkGitIntegrity(workspace);
reliability_1.BuildReliabilityService.cleanStaleLockFiles(workspace);
reliability_1.BuildReliabilityService.validateSubmoduleBackingStores(workspace);
if (buildParameters.cleanReservedFilenames) {
reliability_1.BuildReliabilityService.cleanReservedFilenames(buildParameters.projectPath);
}
if (!isHealthy && buildParameters.gitAutoRecover) {
core.info('Git corruption detected, attempting automatic recovery...');
const recovered = reliability_1.BuildReliabilityService.recoverCorruptedRepo(workspace);
if (!recovered) {
core.warning('Automatic recovery failed. Build may encounter issues.');
}
}
}
else if (buildParameters.cleanReservedFilenames) {
// cleanReservedFilenames can run independently of gitIntegrityCheck
reliability_1.BuildReliabilityService.cleanReservedFilenames(buildParameters.projectPath);
}
let exitCode = -1;
if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
@@ -90,12 +66,6 @@ async function runMain() {
await model_1.Orchestrator.run(buildParameters, baseImage.toString());
exitCode = 0;
}
// Post-build: archive and enforce retention
if (buildParameters.buildArchiveEnabled && exitCode === 0) {
core.info('Archiving build output...');
reliability_1.BuildReliabilityService.archiveBuildOutput(buildParameters.buildPath, buildParameters.buildArchivePath);
reliability_1.BuildReliabilityService.enforceRetention(buildParameters.buildArchivePath, buildParameters.buildArchiveRetention);
}
// Set output
await model_1.Output.setBuildVersion(buildParameters.buildVersion);
await model_1.Output.setAndroidVersionCode(buildParameters.androidVersionCode);
@@ -405,12 +375,6 @@ class BuildParameters {
cacheUnityInstallationOnMac: input_1.default.cacheUnityInstallationOnMac,
unityHubVersionOnMac: input_1.default.unityHubVersionOnMac,
dockerWorkspacePath: input_1.default.dockerWorkspacePath,
gitIntegrityCheck: input_1.default.gitIntegrityCheck,
gitAutoRecover: input_1.default.gitAutoRecover,
cleanReservedFilenames: input_1.default.cleanReservedFilenames,
buildArchiveEnabled: input_1.default.buildArchiveEnabled,
buildArchivePath: input_1.default.buildArchivePath,
buildArchiveRetention: input_1.default.buildArchiveRetention,
};
}
static parseBuildFile(filename, platform, androidExportType) {
@@ -1862,28 +1826,6 @@ class Input {
static get skipActivation() {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
static get gitIntegrityCheck() {
const input = Input.getInput('gitIntegrityCheck') ?? 'false';
return input === 'true';
}
static get gitAutoRecover() {
const input = Input.getInput('gitAutoRecover') ?? 'false';
return input === 'true';
}
static get cleanReservedFilenames() {
const input = Input.getInput('cleanReservedFilenames') ?? 'false';
return input === 'true';
}
static get buildArchiveEnabled() {
const input = Input.getInput('buildArchiveEnabled') ?? 'false';
return input === 'true';
}
static get buildArchivePath() {
return Input.getInput('buildArchivePath') ?? './build-archives';
}
static get buildArchiveRetention() {
return Number.parseInt(Input.getInput('buildArchiveRetention') ?? '30', 10);
}
static ToEnvVarFormat(input) {
if (input.toUpperCase() === input) {
return input;
@@ -9690,535 +9632,6 @@ class ContainerHookService {
exports.ContainerHookService = ContainerHookService;
/***/ }),
/***/ 37347:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.BuildReliabilityService = void 0;
const node_child_process_1 = __nccwpck_require__(17718);
const node_fs_1 = __importDefault(__nccwpck_require__(87561));
const node_path_1 = __importDefault(__nccwpck_require__(49411));
const core = __importStar(__nccwpck_require__(42186));
/**
* 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).
*/
class BuildReliabilityService {
/**
* Run git fsck to check repository integrity.
* Returns true if the repo is healthy, false if corruption detected.
*/
static checkGitIntegrity(repoPath = '.') {
core.info(`[Reliability] Checking git integrity in ${repoPath}`);
try {
const output = (0, node_child_process_1.execSync)(`git -C "${repoPath}" fsck --no-dangling`, {
encoding: 'utf8',
timeout: 120000,
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) {
// 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 = '.') {
const gitDir = node_path_1.default.join(repoPath, '.git');
if (!node_fs_1.default.existsSync(gitDir) || !node_fs_1.default.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) => {
if (!node_fs_1.default.existsSync(directory))
return;
try {
const entries = node_fs_1.default.readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
const fullPath = node_path_1.default.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 = node_path_1.default.relative(gitDir, fullPath);
const isKnownLock = BuildReliabilityService.LOCK_FILE_NAMES.has(entry.name);
const isRefsLock = relativePath.startsWith('refs' + node_path_1.default.sep);
if (isKnownLock || isRefsLock) {
try {
const stat = node_fs_1.default.statSync(fullPath);
const ageMs = now - stat.mtimeMs;
if (ageMs > BuildReliabilityService.LOCK_FILE_MAX_AGE_MS) {
node_fs_1.default.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 = '.') {
const broken = [];
const gitmodulesPath = node_path_1.default.join(repoPath, '.gitmodules');
if (!node_fs_1.default.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 = node_fs_1.default.readFileSync(gitmodulesPath, 'utf8');
const pathMatches = content.matchAll(/path\s*=\s*(.+)/g);
for (const match of pathMatches) {
const submodulePath = match[1].trim();
const gitFile = node_path_1.default.join(repoPath, submodulePath, '.git');
if (!node_fs_1.default.existsSync(gitFile)) {
// Submodule not initialized -- not necessarily broken
continue;
}
try {
const stat = node_fs_1.default.statSync(gitFile);
if (stat.isFile()) {
// .git is a file -- should contain "gitdir: <path>"
const gitFileContent = node_fs_1.default.readFileSync(gitFile, 'utf8').trim();
const gitdirMatch = gitFileContent.match(/^gitdir:\s*(.+)$/);
if (gitdirMatch) {
const backingStore = node_path_1.default.resolve(node_path_1.default.join(repoPath, submodulePath), gitdirMatch[1]);
if (!node_fs_1.default.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) {
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 = '.') {
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');
(0, node_child_process_1.execSync)(`git -C "${repoPath}" fetch --all`, {
encoding: 'utf8',
timeout: 300000,
stdio: ['pipe', 'pipe', 'pipe'],
});
core.info('[Reliability] Recovery: fetch completed');
}
catch (error) {
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) {
const assetsPath = node_path_1.default.join(projectPath, 'Assets');
if (!node_fs_1.default.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 = [];
const scanDirectory = (directory) => {
try {
const entries = node_fs_1.default.readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
const nameWithoutExtension = entry.name.split('.')[0].toLowerCase();
const fullPath = node_path_1.default.join(directory, entry.name);
if (BuildReliabilityService.RESERVED_NAMES.has(nameWithoutExtension)) {
try {
if (entry.isDirectory()) {
node_fs_1.default.rmSync(fullPath, { recursive: true, force: true });
}
else {
node_fs_1.default.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) {
try {
if (process.platform === 'win32') {
const drive = node_path_1.default.parse(directoryPath).root;
const driveLetter = drive.replace(/[:\\\/]/g, '');
const output = (0, node_child_process_1.execFileSync)('wmic', ['logicaldisk', 'where', `DeviceID='${driveLetter}:'`, 'get', 'FreeSpace', '/value'], { encoding: 'utf8', timeout: 10000 });
const match = output.match(/FreeSpace=(\d+)/);
return match ? Number.parseInt(match[1], 10) / (1024 * 1024) : -1;
}
else {
const output = (0, node_child_process_1.execFileSync)('df', ['-BM', '--output=avail', directoryPath], {
encoding: 'utf8',
timeout: 10000,
});
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) {
try {
const stat = node_fs_1.default.statSync(directoryPath);
if (!stat.isDirectory()) {
return stat.size / (1024 * 1024);
}
let totalBytes = 0;
const walkDirectory = (dir) => {
const entries = node_fs_1.default.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = node_path_1.default.join(dir, entry.name);
if (entry.isDirectory()) {
walkDirectory(fullPath);
}
else {
try {
totalBytes += node_fs_1.default.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, archivePath) {
if (!node_fs_1.default.existsSync(sourcePath)) {
core.info(`[Reliability] No build output to archive at ${sourcePath}`);
return;
}
node_fs_1.default.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 = node_path_1.default.join(archivePath, `build-${timestamp}.tar.gz`);
try {
(0, node_child_process_1.execSync)(`tar -czf "${archiveFile}" -C "${node_path_1.default.dirname(sourcePath)}" "${node_path_1.default.basename(sourcePath)}"`, {
encoding: 'utf8',
timeout: 600000,
stdio: ['pipe', 'pipe', 'pipe'],
});
core.info(`[Reliability] Build output archived to ${archiveFile}`);
}
catch (error) {
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 (node_fs_1.default.existsSync(archiveFile)) {
node_fs_1.default.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, retentionDays) {
if (!node_fs_1.default.existsSync(archivePath)) {
return 0;
}
const now = Date.now();
const retentionMs = retentionDays * 24 * 60 * 60 * 1000;
let removed = 0;
try {
const entries = node_fs_1.default.readdirSync(archivePath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = node_path_1.default.join(archivePath, entry.name);
try {
const stat = node_fs_1.default.statSync(fullPath);
const ageMs = now - stat.mtimeMs;
if (ageMs > retentionMs) {
if (entry.isDirectory()) {
node_fs_1.default.rmSync(fullPath, { recursive: true, force: true });
}
else {
node_fs_1.default.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() {
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
(0, node_child_process_1.execSync)('git config --global http.postBuffer 524288000', {
encoding: 'utf8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
});
core.info('[Reliability] Set http.postBuffer=524288000 (500MB)');
}
catch (error) {
core.warning(`[Reliability] Could not set http.postBuffer: ${error.message}`);
}
try {
// Enable long paths on Windows
(0, node_child_process_1.execSync)('git config --global core.longpaths true', {
encoding: 'utf8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
});
core.info('[Reliability] Set core.longpaths=true');
}
catch (error) {
core.warning(`[Reliability] Could not set core.longpaths: ${error.message}`);
}
}
}
exports.BuildReliabilityService = BuildReliabilityService;
// Windows reserved device names that cause Unity asset importer infinite loops
BuildReliabilityService.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
BuildReliabilityService.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)
BuildReliabilityService.LOCK_FILE_MAX_AGE_MS = 10 * 60 * 1000;
/***/ }),
/***/ 9842:
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.BuildReliabilityService = void 0;
var build_reliability_service_1 = __nccwpck_require__(37347);
Object.defineProperty(exports, "BuildReliabilityService", ({ enumerable: true, get: function () { return build_reliability_service_1.BuildReliabilityService; } }));
/***/ }),
/***/ 23451:

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,6 @@ 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';
async function runMain() {
try {
@@ -15,38 +14,11 @@ 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') {
@@ -65,13 +37,6 @@ async function runMain() {
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);

View File

@@ -106,12 +106,6 @@ 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 static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
@@ -248,12 +242,6 @@ 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,
};
}

View File

@@ -282,38 +282,6 @@ class Input {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
static get gitIntegrityCheck(): boolean {
const input = Input.getInput('gitIntegrityCheck') ?? 'false';
return input === 'true';
}
static get gitAutoRecover(): boolean {
const input = Input.getInput('gitAutoRecover') ?? 'false';
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,161 @@
import { OrchestratorFolders } from './orchestrator-folders';
// Mock Orchestrator
jest.mock('../orchestrator', () => ({
__esModule: true,
default: {
buildParameters: {
buildGuid: 'test-guid-abc',
cacheKey: 'my-cache-key',
projectPath: 'test-project',
buildPath: 'Builds',
maxRetainedWorkspaces: 0,
gitPrivateToken: 'ghp_test123',
orchestratorRepoName: 'game-ci/unity-builder',
githubRepo: 'user/my-game',
},
lockedWorkspace: '',
},
}));
jest.mock('../../build-parameters', () => ({
__esModule: true,
default: {
shouldUseRetainedWorkspaceMode: jest.fn().mockReturnValue(false),
},
}));
jest.mock('./orchestrator-options', () => ({
__esModule: true,
default: {
useSharedBuilder: false,
},
}));
// Normalize paths for cross-platform test compatibility
const normalize = (p: string) => p.replace(/\\/g, '/');
describe('OrchestratorFolders', () => {
describe('static constants', () => {
it('repositoryFolder is "repo"', () => {
expect(OrchestratorFolders.repositoryFolder).toBe('repo');
});
it('buildVolumeFolder is "data"', () => {
expect(OrchestratorFolders.buildVolumeFolder).toBe('data');
});
it('cacheFolder is "cache"', () => {
expect(OrchestratorFolders.cacheFolder).toBe('cache');
});
});
describe('ToLinuxFolder', () => {
it('converts backslashes to forward slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('C:\\Users\\test\\project')).toBe('C:/Users/test/project');
});
it('preserves forward slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('/home/user/project')).toBe('/home/user/project');
});
it('handles mixed slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('some/path\\mixed/slashes\\here')).toBe('some/path/mixed/slashes/here');
});
it('handles empty string', () => {
expect(OrchestratorFolders.ToLinuxFolder('')).toBe('');
});
});
describe('path computations (non-retained workspace mode)', () => {
it('uniqueOrchestratorJobFolderAbsolute uses buildGuid', () => {
const result = normalize(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute);
expect(result).toBe('/data/test-guid-abc');
});
it('cacheFolderForAllFull returns /data/cache', () => {
const result = normalize(OrchestratorFolders.cacheFolderForAllFull);
expect(result).toBe('/data/cache');
});
it('cacheFolderForCacheKeyFull includes cache key', () => {
const result = normalize(OrchestratorFolders.cacheFolderForCacheKeyFull);
expect(result).toBe('/data/cache/my-cache-key');
});
it('repoPathAbsolute is under job folder', () => {
const result = normalize(OrchestratorFolders.repoPathAbsolute);
expect(result).toBe('/data/test-guid-abc/repo');
});
it('projectPathAbsolute includes project path', () => {
const result = normalize(OrchestratorFolders.projectPathAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/test-project');
});
it('libraryFolderAbsolute is under project path', () => {
const result = normalize(OrchestratorFolders.libraryFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/test-project/Library');
});
it('projectBuildFolderAbsolute uses buildPath', () => {
const result = normalize(OrchestratorFolders.projectBuildFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/Builds');
});
it('lfsFolderAbsolute is under .git/lfs', () => {
const result = normalize(OrchestratorFolders.lfsFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/.git/lfs');
});
it('lfsCacheFolderFull is under cache key', () => {
const result = normalize(OrchestratorFolders.lfsCacheFolderFull);
expect(result).toBe('/data/cache/my-cache-key/lfs');
});
it('libraryCacheFolderFull is under cache key', () => {
const result = normalize(OrchestratorFolders.libraryCacheFolderFull);
expect(result).toBe('/data/cache/my-cache-key/Library');
});
});
describe('builderPathAbsolute', () => {
it('uses job folder when shared builder is disabled', () => {
const result = normalize(OrchestratorFolders.builderPathAbsolute);
expect(result).toBe('/data/test-guid-abc/builder');
});
});
describe('repo URLs', () => {
it('unityBuilderRepoUrl includes token and repo name', () => {
const url = OrchestratorFolders.unityBuilderRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/game-ci/unity-builder.git');
});
it('targetBuildRepoUrl includes token and github repo', () => {
const url = OrchestratorFolders.targetBuildRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/user/my-game.git');
});
});
describe('purgeRemoteCaching', () => {
it('returns false when env var is not set', () => {
const original = process.env.PURGE_REMOTE_BUILDER_CACHE;
delete process.env.PURGE_REMOTE_BUILDER_CACHE;
expect(OrchestratorFolders.purgeRemoteCaching).toBe(false);
if (original !== undefined) process.env.PURGE_REMOTE_BUILDER_CACHE = original;
});
it('returns true when env var is set', () => {
const original = process.env.PURGE_REMOTE_BUILDER_CACHE;
process.env.PURGE_REMOTE_BUILDER_CACHE = 'true';
expect(OrchestratorFolders.purgeRemoteCaching).toBe(true);
if (original !== undefined) {
process.env.PURGE_REMOTE_BUILDER_CACHE = original;
} else {
delete process.env.PURGE_REMOTE_BUILDER_CACHE;
}
});
});
});

View File

@@ -0,0 +1,53 @@
import OrchestratorNamespace from './orchestrator-guid';
describe('OrchestratorNamespace', () => {
describe('generateGuid', () => {
it('generates a guid with correct format', () => {
const guid = OrchestratorNamespace.generateGuid('42', 'StandaloneLinux64');
// Format: {runNumber}-{platform}-{nanoid4}
expect(guid).toMatch(/^42-linux64-[a-z0-9]{4}$/);
});
it('strips "standalone" prefix from platform (case-insensitive)', () => {
const guid = OrchestratorNamespace.generateGuid('1', 'StandaloneWindows64');
expect(guid).toMatch(/^1-windows64-[a-z0-9]{4}$/);
});
it('lowercases platform name', () => {
const guid = OrchestratorNamespace.generateGuid('5', 'Android');
expect(guid).toMatch(/^5-android-[a-z0-9]{4}$/);
});
it('handles numeric run number', () => {
const guid = OrchestratorNamespace.generateGuid(100, 'iOS');
expect(guid).toMatch(/^100-ios-[a-z0-9]{4}$/);
});
it('generates unique guids on repeated calls', () => {
const guids = new Set<string>();
for (let i = 0; i < 20; i++) {
guids.add(OrchestratorNamespace.generateGuid('1', 'StandaloneLinux64'));
}
// With 4 alphanumeric chars (36^4 = ~1.7M possibilities), 20 calls should almost certainly be unique
expect(guids.size).toBeGreaterThan(1);
});
it('handles StandaloneOSX platform', () => {
const guid = OrchestratorNamespace.generateGuid('7', 'StandaloneOSX');
expect(guid).toMatch(/^7-osx-[a-z0-9]{4}$/);
});
it('handles WebGL platform (no standalone prefix)', () => {
const guid = OrchestratorNamespace.generateGuid('3', 'WebGL');
expect(guid).toMatch(/^3-webgl-[a-z0-9]{4}$/);
});
it('uses only lowercase alphanumeric characters in nanoid portion', () => {
for (let i = 0; i < 10; i++) {
const guid = OrchestratorNamespace.generateGuid('1', 'test');
const nanoidPart = guid.split('-').pop()!;
expect(nanoidPart).toMatch(/^[0-9a-z]{4}$/);
}
});
});
});

View File

@@ -0,0 +1,147 @@
import { FollowLogStreamService } from './follow-log-stream-service';
import * as core from '@actions/core';
import GitHub from '../../../github';
// Mock dependencies
jest.mock('../../../github', () => ({
__esModule: true,
default: {
updateGitHubCheck: jest.fn(),
githubInputEnabled: false,
},
}));
jest.mock('@actions/core', () => ({
warning: jest.fn(),
setOutput: jest.fn(),
setFailed: jest.fn(),
error: jest.fn(),
getInput: jest.fn().mockReturnValue(''),
}));
jest.mock('../../orchestrator', () => ({
__esModule: true,
default: {
buildParameters: {
logId: 'test-log-id-123',
},
},
}));
jest.mock('../../options/orchestrator-statics', () => ({
OrchestratorStatics: {
logPrefix: 'TEST',
},
}));
jest.mock('./orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
},
}));
describe('FollowLogStreamService', () => {
beforeEach(() => {
jest.clearAllMocks();
FollowLogStreamService.Reset();
FollowLogStreamService.errors = '';
});
describe('Reset', () => {
it('resets DidReceiveEndOfTransmission to false', () => {
FollowLogStreamService.DidReceiveEndOfTransmission = true;
FollowLogStreamService.Reset();
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(false);
});
});
describe('handleIteration', () => {
it('detects end of transmission marker', () => {
const result = FollowLogStreamService.handleIteration('---test-log-id-123', true, false, '');
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(true);
expect(result.shouldReadLogs).toBe(false);
});
it('does not trigger end of transmission for non-matching log ID', () => {
const result = FollowLogStreamService.handleIteration('---different-log-id', true, false, '');
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(false);
expect(result.shouldReadLogs).toBe(true);
});
it('detects Library rebuild message', () => {
FollowLogStreamService.handleIteration(
'Rebuilding Library because the asset database could not be found!',
true,
false,
'',
);
expect(GitHub.updateGitHubCheck).toHaveBeenCalledWith('Library was not found, importing new Library', '');
expect(core.warning).toHaveBeenCalledWith('LIBRARY NOT FOUND!');
expect(core.setOutput).toHaveBeenCalledWith('library-found', 'false');
});
it('detects Build succeeded message', () => {
FollowLogStreamService.handleIteration('Build succeeded', true, false, '');
expect(GitHub.updateGitHubCheck).toHaveBeenCalledWith('Build succeeded', 'Build succeeded');
expect(core.setOutput).toHaveBeenCalledWith('build-result', 'success');
});
it('detects Build fail message', () => {
FollowLogStreamService.handleIteration('Build fail', true, false, '');
expect(GitHub.updateGitHubCheck).toHaveBeenCalled();
expect(core.setOutput).toHaveBeenCalledWith('build-result', 'failed');
expect(core.setFailed).toHaveBeenCalledWith('unity build failed');
expect(core.error).toHaveBeenCalledWith('BUILD FAILED!');
});
it('accumulates error messages with "error " pattern', () => {
FollowLogStreamService.handleIteration('error CS0001: Something went wrong', true, false, '');
expect(FollowLogStreamService.errors).toContain('error CS0001: Something went wrong');
expect(core.error).toHaveBeenCalled();
});
it('accumulates error messages with "error: " pattern', () => {
FollowLogStreamService.handleIteration('Fatal Error: Out of memory', true, false, '');
expect(FollowLogStreamService.errors).toContain('Fatal Error: Out of memory');
});
it('accumulates "command failed: " messages', () => {
FollowLogStreamService.handleIteration('command failed: git pull', true, false, '');
expect(FollowLogStreamService.errors).toContain('command failed: git pull');
});
it('accumulates "invalid " messages', () => {
FollowLogStreamService.handleIteration('invalid configuration value', true, false, '');
expect(FollowLogStreamService.errors).toContain('invalid configuration value');
});
it('accumulates "cannot be found" messages', () => {
FollowLogStreamService.handleIteration('Assembly cannot be found', true, false, '');
expect(FollowLogStreamService.errors).toContain('Assembly cannot be found');
});
it('appends message to output', () => {
const result = FollowLogStreamService.handleIteration('Some normal log line', true, false, 'previous output\n');
expect(result.output).toContain('Some normal log line');
expect(result.output).toContain('previous output');
});
it('preserves shouldCleanup value', () => {
const result = FollowLogStreamService.handleIteration('normal message', true, true, '');
expect(result.shouldCleanup).toBe(true);
});
it('does not change shouldReadLogs for normal messages', () => {
const result = FollowLogStreamService.handleIteration('Just a regular build log', true, false, '');
expect(result.shouldReadLogs).toBe(true);
});
it('includes accumulated errors in Build fail GitHub check message', () => {
FollowLogStreamService.errors = '\nprevious error';
FollowLogStreamService.handleIteration('Build fail', true, false, '');
const updateCall = (GitHub.updateGitHubCheck as jest.Mock).mock.calls[0];
expect(updateCall[0]).toContain('previous error');
});
});
});

View File

@@ -0,0 +1,207 @@
import { TaskParameterSerializer } from './task-parameter-serializer';
// Mock dependencies that TaskParameterSerializer uses internally
jest.mock('@actions/core', () => ({
getInput: jest.fn().mockReturnValue(''),
setOutput: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
}));
jest.mock('../../options/orchestrator-options', () => ({
__esModule: true,
default: {
getInput: jest.fn().mockReturnValue(undefined),
ToEnvVarFormat: (input: string) => {
if (input.toUpperCase() === input) {
return input;
}
return input
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase()
.replace(/ /g, '_');
},
},
}));
jest.mock('../../options/orchestrator-options-reader', () => ({
__esModule: true,
default: {
GetProperties: jest.fn().mockReturnValue([]),
},
}));
jest.mock('../../options/orchestrator-query-override', () => ({
__esModule: true,
default: {
queryOverrides: undefined,
},
}));
jest.mock('../hooks/command-hook-service', () => ({
CommandHookService: {
getHooks: jest.fn().mockReturnValue([]),
getSecrets: jest.fn().mockReturnValue([]),
},
}));
jest.mock('../../../input', () => ({
__esModule: true,
default: {},
}));
jest.mock('../../../github', () => ({
__esModule: true,
default: {
githubInputEnabled: false,
},
}));
describe('TaskParameterSerializer', () => {
describe('ToEnvVarFormat', () => {
it('converts camelCase to UPPER_SNAKE_CASE', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('targetPlatform')).toBe('TARGET_PLATFORM');
});
it('converts single word to uppercase', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('version')).toBe('VERSION');
});
it('preserves already-uppercase strings', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('AWS_REGION')).toBe('AWS_REGION');
});
it('handles multi-word camelCase', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('buildPlatformTarget')).toBe('BUILD_PLATFORM_TARGET');
});
it('handles string starting with uppercase', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('BuildGuid')).toBe('BUILD_GUID');
});
});
describe('UndoEnvVarFormat', () => {
it('converts UPPER_SNAKE_CASE back to camelCase', () => {
expect(TaskParameterSerializer.UndoEnvVarFormat('TARGET_PLATFORM')).toBe('targetPlatform');
});
it('handles single word', () => {
expect(TaskParameterSerializer.UndoEnvVarFormat('VERSION')).toBe('version');
});
it('handles multiple underscores', () => {
expect(TaskParameterSerializer.UndoEnvVarFormat('BUILD_PLATFORM_TARGET')).toBe('buildPlatformTarget');
});
});
describe('round-trip conversion', () => {
it('ToEnvVarFormat -> UndoEnvVarFormat returns original for simple camelCase', () => {
const original = 'targetPlatform';
const envVar = TaskParameterSerializer.ToEnvVarFormat(original);
const roundTrip = TaskParameterSerializer.UndoEnvVarFormat(envVar);
expect(roundTrip).toBe(original);
});
it('round-trips multi-word keys', () => {
const original = 'cacheKey';
const envVar = TaskParameterSerializer.ToEnvVarFormat(original);
const roundTrip = TaskParameterSerializer.UndoEnvVarFormat(envVar);
expect(roundTrip).toBe(original);
});
});
describe('uniqBy', () => {
it('removes duplicates by key function', () => {
const items = [
{ name: 'A', value: '1' },
{ name: 'B', value: '2' },
{ name: 'A', value: '3' },
];
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
expect(result).toHaveLength(2);
expect(result[0].value).toBe('1');
expect(result[1].value).toBe('2');
});
it('returns all items when no duplicates', () => {
const items = [
{ name: 'A', value: '1' },
{ name: 'B', value: '2' },
{ name: 'C', value: '3' },
];
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
expect(result).toHaveLength(3);
});
it('handles empty array', () => {
const result = TaskParameterSerializer.uniqBy([], (x) => x.name);
expect(result).toHaveLength(0);
});
it('keeps first occurrence when duplicates exist', () => {
const items = [
{ name: 'KEY', value: 'first' },
{ name: 'KEY', value: 'second' },
{ name: 'KEY', value: 'third' },
];
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
expect(result).toHaveLength(1);
expect(result[0].value).toBe('first');
});
});
describe('blockedParameterNames', () => {
it('contains expected blocked names', () => {
expect(TaskParameterSerializer.blockedParameterNames.has('0')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('length')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('prototype')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('unityVersion')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('CUSTOM_JOB')).toBe(true);
});
it('does not block valid parameter names', () => {
expect(TaskParameterSerializer.blockedParameterNames.has('targetPlatform')).toBe(false);
expect(TaskParameterSerializer.blockedParameterNames.has('buildGuid')).toBe(false);
expect(TaskParameterSerializer.blockedParameterNames.has('cacheKey')).toBe(false);
});
});
describe('readDefaultSecrets', () => {
it('returns an array', () => {
const secrets = TaskParameterSerializer.readDefaultSecrets();
expect(Array.isArray(secrets)).toBe(true);
});
it('includes secrets from environment when present', () => {
const originalSerial = process.env.UNITY_SERIAL;
process.env.UNITY_SERIAL = 'test-serial';
const secrets = TaskParameterSerializer.readDefaultSecrets();
const serialSecret = secrets.find((s) => s.ParameterKey === 'UNITY_SERIAL');
expect(serialSecret).toBeDefined();
expect(serialSecret?.ParameterValue).toBe('test-serial');
if (originalSerial !== undefined) {
process.env.UNITY_SERIAL = originalSerial;
} else {
delete process.env.UNITY_SERIAL;
}
});
it('excludes secrets not in environment', () => {
const originalSerial = process.env.UNITY_SERIAL;
delete process.env.UNITY_SERIAL;
const secrets = TaskParameterSerializer.readDefaultSecrets();
const serialSecret = secrets.find((s) => s.ParameterKey === 'UNITY_SERIAL');
expect(serialSecret).toBeUndefined();
if (originalSerial !== undefined) {
process.env.UNITY_SERIAL = originalSerial;
}
});
});
});

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