Compare commits

..

3 Commits

Author SHA1 Message Date
frostebite
f0b74c7214 feat: add workflow to sync secrets to sibling repositories
Adds a manually-triggered workflow that copies secrets from
unity-builder (repo + org level) to target repos like orchestrator
and cli. Uses GIT_PRIVATE_TOKEN for cross-repo API access.

Secrets synced: UNITY_EMAIL, UNITY_PASSWORD, UNITY_SERIAL,
GIT_PRIVATE_TOKEN, LOCALSTACK_AUTH_TOKEN, GOOGLE_SERVICE_ACCOUNT_EMAIL,
GOOGLE_SERVICE_ACCOUNT_KEY, CODECOV_TOKEN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 02:45:44 +01:00
Frostebite
4a7fc08e63 Fix failing windows builds in CI (#820)
* ci(windows): add Docker daemon readiness check before build

Add a proactive Docker daemon health check step to the Windows build
workflow. The windows-2022 runner images sometimes have the Docker
service in a stopped or starting state, causing the first build attempt
to fail on Docker operations.

The new step polls the Docker service for up to 60 seconds, actively
starting it if stopped, before proceeding to the build. This is faster
and more diagnostic than relying solely on the existing retry loop
(which sleeps 120-240s between full re-runs of the action).

The existing retry pattern is kept as defense-in-depth since it also
handles non-Docker transient failures (Unity licensing, network, etc).

Ref: actions/runner-images#13729

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: apply Prettier formatting to workflow files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:43:13 +00:00
Frostebite
ce7ce7a416 fix: pass Unity license secrets to AWS ECS container via RunTask overrides (#821)
* fix: pass Unity license secrets to AWS ECS container via RunTask overrides

The AWS provider was not passing UNITY_EMAIL, UNITY_PASSWORD, and
UNITY_SERIAL to the ECS container as environment variables. These
secrets were only sent to CloudFormation Secrets Manager, but the
template generation produced duplicate YAML Secrets keys (one per
secret), causing only the last secret to survive. The activate.sh
script requires all three to be present simultaneously.

This fix merges secrets into the ECS RunTask containerOverrides
environment array, matching how the docker and k8s providers already
handle secrets. The CloudFormation Secrets Manager path is preserved
as a secondary mechanism.

Fixes license activation failure when using providerStrategy: aws.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Pin LocalStack to 4.4.0 (pre-auth-token requirement)

As of 2026-03-23, localstack/localstack:latest requires an auth token
even for community features. Pin to 4.4.0 (last community release
before the single-image migration) to restore CI.

See: https://blog.localstack.cloud/localstack-single-image-next-steps/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 19:49:23 +00:00
19 changed files with 145 additions and 1874 deletions

View File

@@ -12,7 +12,6 @@ jobs:
buildForAllPlatformsMacOS:
name: ${{ matrix.targetPlatform }} on ${{ matrix.unityVersion }}
runs-on: macos-latest
continue-on-error: true
strategy:
fail-fast: false
matrix:

View File

@@ -39,7 +39,7 @@ jobs:
- unityVersion: 6000.0.36f1
targetPlatform: StandaloneWindows64
buildProfile: 'Assets/Settings/Build Profiles/Sample Windows Build Profile.asset'
steps:
###########################
# Checkout #
@@ -66,6 +66,34 @@ jobs:
run: |
Move-Item -Path "./test-project/ProjectSettings/ProjectSettingsIl2cpp.asset" -Destination "./test-project/ProjectSettings/ProjectSettings.asset" -Force
###########################
# Docker Readiness #
###########################
- name: Ensure Docker daemon is ready
timeout-minutes: 2
shell: powershell
run: |
$maxRetries = 10
$retryDelay = 6
for ($i = 0; $i -lt $maxRetries; $i++) {
$svc = Get-Service docker -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq 'Running') {
docker version 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host "Docker is ready."
exit 0
}
}
if ($svc -and $svc.Status -eq 'Stopped') {
Write-Host "Docker service stopped, attempting to start..."
Start-Service docker -ErrorAction SilentlyContinue
}
Write-Host "Waiting for Docker daemon (attempt $($i+1)/$maxRetries)..."
Start-Sleep -Seconds $retryDelay
}
Write-Error "Docker daemon did not start within $($maxRetries * $retryDelay) seconds"
exit 1
###########################
# Build #
###########################
@@ -146,6 +174,8 @@ jobs:
###########################
- uses: actions/upload-artifact@v4
with:
name: Build ${{ matrix.targetPlatform }} on Windows (${{ matrix.unityVersion }})${{ matrix.enableGpu && ' With GPU' || '' }}${{ matrix.buildProfile && ' With Build Profile' || '' }}
name:
Build ${{ matrix.targetPlatform }} on Windows (${{ matrix.unityVersion }})${{ matrix.enableGpu && ' With
GPU' || '' }}${{ matrix.buildProfile && ' With Build Profile' || '' }}
path: build
retention-days: 14

View File

@@ -54,7 +54,7 @@ jobs:
# AWS_STACK_NAME: game-ci-github-pipelines
CHECKS_UPDATE: ${{ github.event.inputs.checksObject }}
run: |
git clone -b main https://github.com/game-ci/unity-builder
git clone -b orchestrator-develop https://github.com/game-ci/unity-builder
cd unity-builder
yarn
ls

View File

@@ -91,7 +91,7 @@ jobs:
-e SERVICES=s3,cloudformation,ecs,kinesis,cloudwatch,logs,efs,ec2,iam,elasticfilesystem,secretsmanager,lambda,events,sts \
-e DEBUG=0 \
-e HOSTNAME_EXTERNAL=localstack-main \
localstack/localstack:latest || true
localstack/localstack:4.4.0 || true
# Wait for LocalStack to be ready - check both health endpoint and S3 service
echo "Waiting for LocalStack to be ready..."
MAX_ATTEMPTS=60

81
.github/workflows/sync-secrets.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: Sync Secrets to Repositories
on:
workflow_dispatch:
inputs:
target_repo:
description: 'Target repository (org/repo format)'
required: true
default: 'game-ci/orchestrator'
type: choice
options:
- game-ci/orchestrator
- game-ci/cli
dry_run:
description: 'Dry run (list secrets to sync without writing)'
required: false
default: false
type: boolean
jobs:
sync-secrets:
name: Sync secrets to ${{ inputs.target_repo }}
runs-on: ubuntu-latest
steps:
- name: Sync secrets
env:
GH_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
TARGET_REPO: ${{ inputs.target_repo }}
DRY_RUN: ${{ inputs.dry_run }}
# Secrets to sync — values come from repo + org secrets available here
SECRET_UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
SECRET_UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
SECRET_UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
SECRET_GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
SECRET_LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }}
SECRET_GOOGLE_SERVICE_ACCOUNT_EMAIL: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_EMAIL }}
SECRET_GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
SECRET_CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: |
SECRETS=(
"UNITY_EMAIL:SECRET_UNITY_EMAIL"
"UNITY_PASSWORD:SECRET_UNITY_PASSWORD"
"UNITY_SERIAL:SECRET_UNITY_SERIAL"
"GIT_PRIVATE_TOKEN:SECRET_GIT_PRIVATE_TOKEN"
"LOCALSTACK_AUTH_TOKEN:SECRET_LOCALSTACK_AUTH_TOKEN"
"GOOGLE_SERVICE_ACCOUNT_EMAIL:SECRET_GOOGLE_SERVICE_ACCOUNT_EMAIL"
"GOOGLE_SERVICE_ACCOUNT_KEY:SECRET_GOOGLE_SERVICE_ACCOUNT_KEY"
"CODECOV_TOKEN:SECRET_CODECOV_TOKEN"
)
synced=0
skipped=0
for entry in "${SECRETS[@]}"; do
name="${entry%%:*}"
env_var="${entry##*:}"
value="${!env_var}"
if [ -z "$value" ]; then
echo "⏭ SKIP: $name (not available in this repo's context)"
skipped=$((skipped + 1))
continue
fi
if [ "$DRY_RUN" = "true" ]; then
echo "🔍 DRY RUN: would sync $name → $TARGET_REPO"
else
echo "$value" | gh secret set "$name" -R "$TARGET_REPO" --body -
echo "✅ SYNCED: $name → $TARGET_REPO"
fi
synced=$((synced + 1))
done
echo ""
echo "=== Summary ==="
echo "Synced: $synced"
echo "Skipped (not available): $skipped"
echo "Target: $TARGET_REPO"
if [ "$DRY_RUN" = "true" ]; then
echo "Mode: DRY RUN (no secrets were written)"
fi

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:

604
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;
@@ -3456,7 +3398,7 @@ class AWSTaskRunner {
return { name: x.name, value };
});
}
static async runTask(taskDef, environment, commands) {
static async runTask(taskDef, environment, secrets, commands) {
const cluster = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ECSCluster')?.PhysicalResourceId || '';
const taskDefinition = taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'TaskDefinition')?.PhysicalResourceId || '';
const SubnetOne = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'PublicSubnetOne')?.PhysicalResourceId || '';
@@ -3465,6 +3407,11 @@ class AWSTaskRunner {
const streamName = taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'KinesisStream')?.PhysicalResourceId || '';
// Transform localhost endpoints for container environment
const transformedEnvironment = AWSTaskRunner.transformEndpointsForContainer(environment);
// Merge secrets into environment as plain env vars, matching docker and k8s provider behavior.
// This ensures UNITY_EMAIL, UNITY_PASSWORD, UNITY_SERIAL reach the container reliably
// without depending on CloudFormation Secrets Manager resolution.
const secretsAsEnvironment = secrets.map((s) => ({ name: s.EnvironmentVariable, value: s.ParameterValue }));
const mergedEnvironment = [...transformedEnvironment, ...secretsAsEnvironment];
const runParameters = {
cluster,
taskDefinition,
@@ -3473,7 +3420,7 @@ class AWSTaskRunner {
containerOverrides: [
{
name: taskDef.taskDefStackName,
environment: transformedEnvironment,
environment: mergedEnvironment,
command: ['-c', command_hook_service_1.CommandHookService.ApplyHooksToCommands(commands, orchestrator_1.default.buildParameters)],
},
],
@@ -4507,7 +4454,7 @@ class AWSBuildEnvironment {
try {
const postSetupStacksTimeMs = Date.now();
orchestrator_logger_1.default.log(`Setup job time: ${Math.floor((postSetupStacksTimeMs - startTimeMs) / 1000)}s`);
const { output, shouldCleanup } = await aws_task_runner_1.default.runTask(taskDef, environment, commands);
const { output, shouldCleanup } = await aws_task_runner_1.default.runTask(taskDef, environment, secrets, commands);
postRunTaskTimeMs = Date.now();
orchestrator_logger_1.default.log(`Run job time: ${Math.floor((postRunTaskTimeMs - postSetupStacksTimeMs) / 1000)}s`);
if (shouldCleanup) {
@@ -9690,535 +9637,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:
@@ -10318,7 +9736,8 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" /builder
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b main "$REPO" /builder \
git clone -q -b orchestrator-develop "$REPO" /builder \
|| git clone -q -b main "$REPO" /builder \
|| git clone -q "$REPO" /builder
fi
git clone -q -b ${orchestrator_1.default.buildParameters.branch} ${orchestrator_folders_1.OrchestratorFolders.targetBuildRepoUrl} /repo
@@ -10435,7 +9854,8 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" "$DEST"
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b main "$REPO" "$DEST" \
git clone -q -b orchestrator-develop "$REPO" "$DEST" \
|| git clone -q -b main "$REPO" "$DEST" \
|| git clone -q "$REPO" "$DEST"
fi
chmod +x ${builderPath}`;

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

@@ -1,6 +1,7 @@
import { DescribeTasksCommand, RunTaskCommand, waitUntilTasksRunning } from '@aws-sdk/client-ecs';
import { DescribeStreamCommand, GetRecordsCommand, GetShardIteratorCommand } from '@aws-sdk/client-kinesis';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorSecret from '../../options/orchestrator-secret';
import * as core from '@actions/core';
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def';
import * as zlib from 'node:zlib';
@@ -56,6 +57,7 @@ class AWSTaskRunner {
static async runTask(
taskDef: OrchestratorAWSTaskDef,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
commands: string,
): Promise<{ output: string; shouldCleanup: boolean }> {
const cluster = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ECSCluster')?.PhysicalResourceId || '';
@@ -73,6 +75,12 @@ class AWSTaskRunner {
// Transform localhost endpoints for container environment
const transformedEnvironment = AWSTaskRunner.transformEndpointsForContainer(environment);
// Merge secrets into environment as plain env vars, matching docker and k8s provider behavior.
// This ensures UNITY_EMAIL, UNITY_PASSWORD, UNITY_SERIAL reach the container reliably
// without depending on CloudFormation Secrets Manager resolution.
const secretsAsEnvironment = secrets.map((s) => ({ name: s.EnvironmentVariable, value: s.ParameterValue }));
const mergedEnvironment = [...transformedEnvironment, ...secretsAsEnvironment];
const runParameters = {
cluster,
taskDefinition,
@@ -81,7 +89,7 @@ class AWSTaskRunner {
containerOverrides: [
{
name: taskDef.taskDefStackName,
environment: transformedEnvironment,
environment: mergedEnvironment,
command: ['-c', CommandHookService.ApplyHooksToCommands(commands, Orchestrator.buildParameters)],
},
],

View File

@@ -125,7 +125,7 @@ class AWSBuildEnvironment implements ProviderInterface {
try {
const postSetupStacksTimeMs = Date.now();
OrchestratorLogger.log(`Setup job time: ${Math.floor((postSetupStacksTimeMs - startTimeMs) / 1000)}s`);
const { output, shouldCleanup } = await AwsTaskRunner.runTask(taskDef, environment, commands);
const { output, shouldCleanup } = await AwsTaskRunner.runTask(taskDef, environment, secrets, commands);
postRunTaskTimeMs = Date.now();
OrchestratorLogger.log(`Run job time: ${Math.floor((postRunTaskTimeMs - postSetupStacksTimeMs) / 1000)}s`);
if (shouldCleanup) {

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

View File

@@ -30,7 +30,7 @@ describe('Orchestrator Caching', () => {
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
containerHookFiles: `debug-cache`,
orchestratorBranch: `main`,
orchestratorBranch: `orchestrator-develop`,
orchestratorDebug: true,
};

View File

@@ -33,7 +33,8 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" /builder
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b main "$REPO" /builder \
git clone -q -b orchestrator-develop "$REPO" /builder \
|| git clone -q -b main "$REPO" /builder \
|| git clone -q "$REPO" /builder
fi
git clone -q -b ${Orchestrator.buildParameters.branch} ${OrchestratorFolders.targetBuildRepoUrl} /repo

View File

@@ -99,7 +99,8 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" "$DEST"
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b main "$REPO" "$DEST" \
git clone -q -b orchestrator-develop "$REPO" "$DEST" \
|| git clone -q -b main "$REPO" "$DEST" \
|| git clone -q "$REPO" "$DEST"
fi
chmod +x ${builderPath}`;