Compare commits

..

17 Commits

Author SHA1 Message Date
frostebite
bc9332e6ab refactor(cli): move cache command under orchestrate subcommand
Cache is an orchestrator feature, so it belongs under `game-ci orchestrate cache`
rather than as a top-level `game-ci cache` command.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:17:47 +00:00
frostebite
d53ed6c519 fix: remove stale merge conflict marker from action.yml
A leftover <<<<<<< HEAD marker was causing all builds on this branch
to fail with YAML parse error at line 305.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:28:17 +00:00
frostebite
4b44327092 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 19:49:31 +00:00
frostebite
bf25e1fb84 fix: downgrade yargs to ^17.7.2 for Node 18 compatibility
yargs@18 requires Node >=20.19.0. Downgrade to yargs@17.7.2 to
maintain Node 18 support. Revert integrity-check.yml back to Node 18.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 07:59:23 +00:00
frostebite
0633ca96ea fix(ci): bump node version to 20 in integrity-check
yargs@18.0.0 requires Node >=20.19.0, so Node 18 is no longer
compatible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 05:47:14 +00:00
frostebite
b774d7fef1 fix(test): add gitAuthMode to orchestrator-folders test mock
The test mock was missing gitAuthMode, causing useHeaderAuth to
default to true and strip the token from repo URLs. Adding
gitAuthMode: 'url' restores the expected URL-mode behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 05:46:17 +00:00
frostebite
c0ca4b6b6c revert: restore build-tests-mac.yml to match main
Stop modifying the macOS build workflow — leave it identical to main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:56:41 +00:00
frostebite
78a7f6cd2e ci: mark failed macOS builds as neutral instead of failure
Use the Checks API to flip failed macOS build conclusions to neutral
(gray dash) so unstable builds don't show red X marks on PRs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:20:34 +00:00
frostebite
676855fdaa ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:36:49 +00:00
frostebite
db27f9108d chore: add release/lts-infrastructure to update-all script 2026-03-05 23:34:27 +00:00
frostebite
4c91a33ee2 chore: add integration branch update scripts for release/next-gen 2026-03-05 21:02:59 +00:00
frostebite
9f0c5b3f1a Merge remote-tracking branch 'origin/feature/cli-support' into release/next-gen
# Conflicts:
#	dist/index.js.map
2026-03-05 21:02:15 +00:00
frostebite
d81990cb7f Merge remote-tracking branch 'origin/feature/community-plugin-validation' into release/next-gen
# Conflicts:
#	dist/index.js.map
2026-03-05 21:02:05 +00:00
frostebite
8b0565569e Merge remote-tracking branch 'origin/feature/incremental-sync-protocol' into release/next-gen
# Conflicts:
#	action.yml
#	dist/index.js
#	dist/index.js.map
#	src/index.ts
#	src/model/build-parameters.ts
2026-03-05 21:01:54 +00:00
frostebite
708e6d796c Merge remote-tracking branch 'origin/feature/generic-artifact-system' into release/next-gen
# Conflicts:
#	dist/index.js
#	dist/index.js.map
#	src/index.ts
#	src/model/build-parameters.ts
2026-03-05 20:59:52 +00:00
frostebite
8d670d7936 Merge remote-tracking branch 'origin/feature/hot-runner-protocol' into release/next-gen
# Conflicts:
#	dist/index.js
#	dist/index.js.map
#	src/index.ts
#	src/model/build-parameters.ts
#	src/model/input.ts
2026-03-05 20:58:35 +00:00
frostebite
f42930df14 Merge remote-tracking branch 'origin/feature/test-workflow-engine' into release/next-gen 2026-03-05 20:55:14 +00:00
58 changed files with 652 additions and 17711 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -105,12 +105,6 @@ inputs:
required: false
default: ''
description: '[Orchestrator] Github private token to pull from github'
gitAuthMode:
required: false
default: 'header'
description:
'[Orchestrator] How git authentication is configured. "header" (default) uses http.extraHeader so the token
never appears in clone URLs or git config. "url" embeds the token in clone URLs (legacy behavior).'
githubOwner:
required: false
default: ''
@@ -200,51 +194,6 @@ inputs:
description:
'[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
be configured.'
fallbackProviderStrategy:
default: ''
required: false
description:
'[Orchestrator] Fallback provider when the primary is unavailable. Used with runnerCheckEnabled for automatic
failover, or as a catch-all if the primary provider fails to initialize.'
runnerCheckEnabled:
default: 'false'
required: false
description:
'[Orchestrator] Check GitHub Actions runner availability before starting a build. When no suitable runners are
available and fallbackProviderStrategy is set, automatically routes to the fallback provider.'
runnerCheckLabels:
default: ''
required: false
description:
'[Orchestrator] Comma-separated runner labels to filter when checking availability (e.g. self-hosted,linux).
When empty, checks all runners in the repository.'
runnerCheckMinAvailable:
default: '1'
required: false
description:
'[Orchestrator] Minimum number of idle runners required for the primary provider. If fewer are available,
routes to fallbackProviderStrategy.'
retryOnFallback:
default: 'false'
required: false
description:
'[Orchestrator] When true and fallbackProviderStrategy is set, automatically retry the build on the fallback
provider if the primary provider fails. Useful for long builds where transient cloud failures are common.'
providerInitTimeout:
default: '0'
required: false
description:
'[Orchestrator] Maximum seconds to wait for the primary provider to initialize (setupWorkflow). If exceeded
and fallbackProviderStrategy is set, switches to the fallback. Set to 0 to disable (default).'
secretSource:
default: ''
required: false
description:
'[Orchestrator] Premade secret source for pulling build secrets. Supported values: aws-secrets-manager,
aws-parameter-store, gcp-secret-manager, azure-key-vault, hashicorp-vault, hashicorp-vault-kv1,
vault (alias for hashicorp-vault), env. Can also be a custom shell command with {0} placeholder
for the key, or a path to a YAML file defining custom sources. Takes precedence over
inputPullCommand when set.'
resourceTracking:
default: 'false'
required: false
@@ -352,312 +301,6 @@ inputs:
description:
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
fixes.'
submoduleProfilePath:
required: false
default: ''
description:
'Path to a YAML submodule profile file (relative to repo root). Defines which submodules to initialize (branch:
main) or skip (branch: empty). See docs for format.'
submoduleVariantPath:
required: false
default: ''
description:
'Path to a YAML variant overlay file that modifies the base submodule profile. Used for server or debug build
variants.'
submoduleToken:
required: false
default: ''
description:
'Git token for authenticating submodule clones. Falls back to gitPrivateToken or GITHUB_TOKEN if empty.'
localCacheEnabled:
required: false
default: 'false'
description:
'Enable filesystem-based caching for local builds. Caches the Unity Library folder and optionally LFS objects
between builds without requiring actions/cache.'
localCacheRoot:
required: false
default: ''
description:
'Root directory for local build cache. Defaults to $RUNNER_TEMP/game-ci-cache or .game-ci/cache if RUNNER_TEMP is
not set.'
localCacheLibrary:
required: false
default: 'true'
description: 'Cache the Unity Library folder for local builds. Only effective when localCacheEnabled is true.'
localCacheLfs:
required: false
default: 'false'
description: 'Cache Git LFS objects for local builds. Only effective when localCacheEnabled is true.'
childWorkspacesEnabled:
required: false
default: 'false'
description:
'Enable child workspace isolation for multi-product builds. Uses atomic filesystem moves for O(1) workspace
restore instead of tar/download/extract. Ideal for 50GB+ workspaces on self-hosted runners.'
childWorkspaceName:
required: false
default: ''
description:
'Name for this child workspace (e.g., product name like "TurnOfWar"). Used as the cache key for workspace
isolation. Required when childWorkspacesEnabled is true.'
childWorkspaceCacheRoot:
required: false
default: ''
description:
'Parent directory for cached child workspaces. Should be on the same NTFS volume as the build directory for O(1)
atomic restore via filesystem rename. Defaults to $RUNNER_TEMP/game-ci-workspaces.'
childWorkspacePreserveGit:
required: false
default: 'true'
description:
'Preserve .git directory in cached child workspace. Enables delta operations on restore but increases cache size.
Set to false to save disk space at the cost of full re-clone on restore.'
childWorkspaceSeparateLibrary:
required: false
default: 'true'
description:
'Cache Unity Library folder separately from the child workspace. Allows independent Library restore even when
workspace cache is invalidated. Recommended for large projects.'
lfsTransferAgent:
required: false
default: ''
description:
'Custom Git LFS transfer agent. Set to "elastic-git-storage" for built-in support (auto-installs from GitHub
releases). Append @version for a specific release (e.g. "elastic-git-storage@v1.0.0"). Or provide a path to any
custom transfer agent executable. When set, the agent is registered via git config before LFS operations.'
lfsTransferAgentArgs:
required: false
default: ''
description: 'Additional arguments to pass to the custom LFS transfer agent.'
lfsStoragePaths:
required: false
default: ''
description:
'Semicolon-separated list of storage paths for the custom LFS transfer agent. Interpretation depends on the agent
(e.g. local paths, WebDAV URLs, rclone remotes).'
gitHooksEnabled:
required: false
default: 'false'
description:
'Install and run git hooks (lefthook, husky, or native) during builds. When false (default), hooks are disabled
for build performance.'
gitHooksSkipList:
required: false
default: ''
description:
'Comma-separated list of hook names to skip even when gitHooksEnabled is true. Example: pre-push,post-merge'
gitHooksRunBeforeBuild:
required: false
default: ''
description:
'Comma-separated list of lefthook hook groups to run before the Unity build. Allows CI to trigger checks that
normally only run on git events. Example: pre-commit,pre-push. Requires lefthook. Works with Unity Git Hooks
(com.frostebite.unitygithooks) when installed as a UPM package — the init script runs automatically.'
providerExecutable:
required: false
default: ''
description:
'Path to an external CLI executable that implements the provider protocol. Enables providers written in any
language (Go, Python, Rust, shell). Uses JSON-over-stdin/stdout communication.'
gcpProject:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Google Cloud project ID for Cloud Run Jobs provider. Falls back to
GOOGLE_CLOUD_PROJECT env var.'
gcpRegion:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Google Cloud region for Cloud Run Jobs (e.g. us-central1). Defaults to the region
input if empty.'
gcpStorageType:
required: false
default: 'gcs-fuse'
description:
'[Orchestrator] [Experimental] Storage type for Cloud Run Jobs. Options: gcs-fuse (mount GCS bucket as filesystem,
unlimited size, best for large sequential I/O), gcs-copy (copy artifacts in/out via gsutil, simpler, no FUSE
overhead), nfs (Filestore NFS mount, true POSIX, good random I/O, up to 100 TiB), in-memory (tmpfs, fastest but
volatile, up to 32 GiB).'
gcpBucket:
required: false
default: ''
description:
'[Orchestrator] [Experimental] GCS bucket name for build artifact storage. Used by gcs-fuse and gcs-copy storage
types.'
gcpFilestoreIp:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Filestore instance IP address for NFS storage type. Required when gcpStorageType is
nfs.'
gcpFilestoreShare:
required: false
default: '/share1'
description:
'[Orchestrator] [Experimental] Filestore share name for NFS storage type. Defaults to /share1 (the Filestore
default).'
gcpMachineType:
required: false
default: 'e2-standard-4'
description: '[Orchestrator] [Experimental] Machine type for Cloud Run Jobs (e.g. e2-standard-4, e2-highmem-8).'
gcpDiskSizeGb:
required: false
default: '100'
description:
'[Orchestrator] [Experimental] Disk size in GB for Cloud Run Jobs in-memory volumes. Only applies to in-memory
storage type (max 32).'
gcpServiceAccount:
required: false
default: ''
description: '[Orchestrator] [Experimental] Google Cloud service account email for Cloud Run Jobs execution.'
gcpVpcConnector:
required: false
default: ''
description: '[Orchestrator] [Experimental] VPC connector name for Cloud Run Jobs private networking.'
azureResourceGroup:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure resource group for Container Instances provider. Falls back to
AZURE_RESOURCE_GROUP env var.'
azureLocation:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure region for Container Instances (e.g. eastus, westeurope). Defaults to the
region input if empty.'
azureStorageType:
required: false
default: 'azure-files'
description:
'[Orchestrator] [Experimental] Storage type for Azure Container Instances. Options: azure-files (SMB file share
mount, up to 100 TiB, premium throughput), blob-copy (copy artifacts in/out via az storage blob, no mount
overhead), azure-files-nfs (NFS 4.1 file share mount, true POSIX, no SMB lock overhead), in-memory (emptyDir
tmpfs, fastest but volatile, size limited by container memory).'
azureStorageAccount:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure Storage Account name. Used by azure-files, azure-files-nfs, and blob-copy
storage types.'
azureFileShareName:
required: false
default: 'unity-builds'
description:
'[Orchestrator] [Experimental] Azure File Share name within the storage account. Used by azure-files and
azure-files-nfs storage types. Supports up to 100 TiB per share.'
azureBlobContainer:
required: false
default: 'unity-builds'
description: '[Orchestrator] [Experimental] Azure Blob container name for blob-copy storage type.'
azureSubscriptionId:
required: false
default: ''
description: '[Orchestrator] [Experimental] Azure subscription ID. Falls back to AZURE_SUBSCRIPTION_ID env var.'
azureCpu:
required: false
default: '4'
description: '[Orchestrator] [Experimental] CPU cores for Azure Container Instances (1-16).'
azureMemoryGb:
required: false
default: '16'
description: '[Orchestrator] [Experimental] Memory in GB for Azure Container Instances (1-16).'
azureDiskSizeGb:
required: false
default: '100'
description:
'[Orchestrator] [Experimental] File share quota in GB for Azure Container Instances. Premium shares support up to
102400 GB (100 TiB).'
azureSubnetId:
required: false
default: ''
description: '[Orchestrator] [Experimental] Azure subnet resource ID for VNet-integrated Container Instances.'
remotePowershellHost:
default: ''
required: false
description: '[Orchestrator] Remote PowerShell host (hostname or IP) for the remote-powershell provider'
remotePowershellCredential:
default: ''
required: false
description: '[Orchestrator] Remote PowerShell credential (username:password or certificate path)'
remotePowershellTransport:
default: 'wsman'
required: false
description: '[Orchestrator] Remote PowerShell transport protocol (wsman or ssh)'
githubActionsRepo:
default: ''
required: false
description: '[Orchestrator] Target repository (owner/repo) for the github-actions provider'
githubActionsWorkflow:
default: ''
required: false
description: '[Orchestrator] Workflow filename or ID to dispatch for the github-actions provider'
githubActionsToken:
default: ''
required: false
description: '[Orchestrator] PAT with actions:write scope for the github-actions provider'
githubActionsRef:
default: 'main'
required: false
description: '[Orchestrator] Branch/ref to run the workflow on for the github-actions provider'
gitlabProjectId:
default: ''
required: false
description: '[Orchestrator] GitLab project ID or URL-encoded path for the gitlab-ci provider'
gitlabTriggerToken:
default: ''
required: false
description: '[Orchestrator] Pipeline trigger token for the gitlab-ci provider'
gitlabApiUrl:
default: 'https://gitlab.com'
required: false
description: '[Orchestrator] GitLab API URL (for self-hosted instances) for the gitlab-ci provider'
gitlabRef:
default: 'main'
required: false
description: '[Orchestrator] Branch/ref to trigger the pipeline on for the gitlab-ci provider'
ansibleInventory:
default: ''
required: false
description: '[Orchestrator] Path to Ansible inventory file or dynamic inventory script'
ansiblePlaybook:
default: ''
required: false
description: '[Orchestrator] Path to Ansible playbook for Unity builds'
ansibleExtraVars:
default: ''
required: false
description: '[Orchestrator] Additional Ansible variables as JSON'
ansibleVaultPassword:
default: ''
required: false
description: '[Orchestrator] Path to Ansible vault password file'
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'
testSuitePath:
description: 'Path to YAML test suite definition file'
required: false

View File

@@ -1,29 +1,18 @@
# delete-me-update-this-integration-branch.ps1
# Run this script from the repo root while on the release/lts-2.0.0 branch.
# Run this script from the repo root while on the release/next-gen branch.
# It merges the latest from each component branch to keep this integration branch current.
# After running, review any conflicts, then commit and push.
$ErrorActionPreference = 'Stop'
$branchName = git rev-parse --abbrev-ref HEAD
if ($branchName -ne 'release/lts-2.0.0') {
Write-Error "Must be on release/lts-2.0.0 branch. Currently on: $branchName"
if ($branchName -ne 'release/next-gen') {
Write-Error "Must be on release/next-gen branch. Currently on: $branchName"
exit 1
}
# Component branches for this integration branch
$branches = @(
# Infrastructure
'feature/orchestrator-enterprise-support'
'feature/cloud-run-azure-providers'
'feature/provider-load-balancing'
'feature/orchestrator-unit-tests'
'fix/secure-git-token-usage'
'feature/premade-secret-sources'
'feature/ci-platform-providers'
'feature/build-reliability'
'ci/orchestrator-integrity-speedup'
# Next-gen
'feature/test-workflow-engine'
'feature/hot-runner-protocol'
'feature/generic-artifact-system'
@@ -56,5 +45,5 @@ if ($failed.Count -gt 0) {
$failed | ForEach-Object { Write-Host " git merge origin/$_" -ForegroundColor Yellow }
} else {
Write-Host "`nAll branches merged successfully!" -ForegroundColor Green
Write-Host "Run 'git push origin release/lts-2.0.0' to update the remote." -ForegroundColor Cyan
Write-Host "Run 'git push origin release/next-gen' to update the remote." -ForegroundColor Cyan
}

4754
dist/index.js generated vendored

File diff suppressed because it is too large Load Diff

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,606 +0,0 @@
/**
* Integration wiring tests for enterprise features in index.ts
*
* These tests verify the conditional gating logic in runMain():
* - Each enterprise feature is only invoked when its gate condition is met
* - Services are NOT called when their feature is disabled (the default)
* - The order of operations is correct (restore before build, save after build)
*/
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
import { BuildParameters } from './model';
// ---------------------------------------------------------------------------
// Service mocks — must be declared before importing index.ts (jest hoists them)
// ---------------------------------------------------------------------------
const mockChildWorkspaceService = {
buildConfig: jest.fn().mockReturnValue({ enabled: true, workspaceName: 'Test' }),
initializeWorkspace: jest.fn().mockReturnValue(false),
getWorkspaceSize: jest.fn().mockReturnValue('0 B'),
saveWorkspace: jest.fn(),
};
const mockSubmoduleProfileService = {
createInitPlan: jest.fn().mockResolvedValue([]),
execute: jest.fn().mockResolvedValue(''),
};
const mockLfsAgentService = {
configure: jest.fn().mockResolvedValue(''),
};
const mockLocalCacheService = {
resolveCacheRoot: jest.fn().mockReturnValue('/cache'),
generateCacheKey: jest.fn().mockReturnValue('key-1'),
restoreLfsCache: jest.fn().mockResolvedValue(true),
restoreLibraryCache: jest.fn().mockResolvedValue(true),
saveLibraryCache: jest.fn().mockResolvedValue(''),
saveLfsCache: jest.fn().mockResolvedValue(''),
};
const mockGitHooksService = {
installHooks: jest.fn().mockResolvedValue(''),
configureSkipList: jest.fn().mockReturnValue({ LEFTHOOK_EXCLUDE: 'pre-commit' }),
};
// Mock the dynamic import() targets — jest.mock with factory functions.
// The services are imported dynamically via `await import(...)` in index.ts,
// so we mock the module path and return the mock objects as named exports.
jest.mock('./model/orchestrator/services/cache/child-workspace-service', () => ({
ChildWorkspaceService: mockChildWorkspaceService,
}));
jest.mock('./model/orchestrator/services/submodule/submodule-profile-service', () => ({
SubmoduleProfileService: mockSubmoduleProfileService,
}));
jest.mock('./model/orchestrator/services/lfs/lfs-agent-service', () => ({
LfsAgentService: mockLfsAgentService,
}));
jest.mock('./model/orchestrator/services/cache/local-cache-service', () => ({
LocalCacheService: mockLocalCacheService,
}));
jest.mock('./model/orchestrator/services/hooks/git-hooks-service', () => ({
GitHooksService: mockGitHooksService,
}));
// Mock all non-enterprise dependencies to isolate the wiring logic
jest.mock('@actions/core');
jest.mock('./model', () => ({
Action: {
checkCompatibility: jest.fn(),
workspace: '/workspace',
actionFolder: '/action',
},
BuildParameters: {
create: jest.fn(),
},
Cache: {
verify: jest.fn(),
},
Orchestrator: {
run: jest.fn().mockResolvedValue(''),
},
Docker: {
run: jest.fn().mockResolvedValue(0),
},
ImageTag: jest.fn().mockImplementation(() => ({
toString: () => 'mock-image:latest',
})),
Output: {
setBuildVersion: jest.fn().mockResolvedValue(''),
setAndroidVersionCode: jest.fn().mockResolvedValue(''),
setEngineExitCode: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('./model/cli/cli', () => ({
Cli: {
InitCliMode: jest.fn().mockReturnValue(false),
},
}));
jest.mock('./model/mac-builder', () => ({
__esModule: true,
default: {
run: jest.fn().mockResolvedValue(0),
},
}));
jest.mock('./model/platform-setup', () => ({
__esModule: true,
default: {
setup: jest.fn().mockResolvedValue(''),
},
}));
const mockedBuildParametersCreate = BuildParameters.create as jest.Mock;
interface EnterpriseBuildParametersOverrides {
providerStrategy?: string;
childWorkspacesEnabled?: boolean;
childWorkspaceName?: string;
childWorkspaceCacheRoot?: string;
childWorkspacePreserveGit?: boolean;
childWorkspaceSeparateLibrary?: boolean;
submoduleProfilePath?: string;
submoduleVariantPath?: string;
submoduleToken?: string;
gitPrivateToken?: string;
lfsTransferAgent?: string;
lfsTransferAgentArgs?: string;
lfsStoragePaths?: string;
localCacheEnabled?: boolean;
localCacheRoot?: string;
localCacheLibrary?: boolean;
localCacheLfs?: boolean;
gitHooksEnabled?: boolean;
gitHooksSkipList?: string;
gitHooksRunBeforeBuild?: string;
}
function createMockBuildParameters(overrides: EnterpriseBuildParametersOverrides = {}) {
return {
// Required base properties
providerStrategy: 'local',
targetPlatform: 'StandaloneLinux64',
editorVersion: '2021.3.1f1',
buildVersion: '1.0.0',
androidVersionCode: '1',
projectPath: '.',
branch: 'main',
runnerTempPath: '/tmp',
// Enterprise features - all disabled by default
childWorkspacesEnabled: false,
childWorkspaceName: '',
childWorkspaceCacheRoot: '',
childWorkspacePreserveGit: true,
childWorkspaceSeparateLibrary: true,
submoduleProfilePath: '',
submoduleVariantPath: '',
submoduleToken: '',
gitPrivateToken: '',
lfsTransferAgent: '',
lfsTransferAgentArgs: '',
lfsStoragePaths: '',
localCacheEnabled: false,
localCacheRoot: '',
localCacheLibrary: true,
localCacheLfs: false,
gitHooksEnabled: false,
gitHooksSkipList: '',
gitHooksRunBeforeBuild: '',
...overrides,
};
}
/**
* The entry point (runMain) is invoked by importing index.ts.
* Since it calls `runMain()` at module scope, we need to re-import it
* for each test. jest.isolateModules() handles this.
*/
async function runIndex(overrides: EnterpriseBuildParametersOverrides = {}): Promise<void> {
mockedBuildParametersCreate.mockResolvedValue(createMockBuildParameters(overrides));
return new Promise<void>((resolve) => {
jest.isolateModules(() => {
require('./index');
// runMain() is async; give it a tick to complete
// We use setImmediate to ensure all microtasks from the dynamic imports resolve
});
// Allow all promises and microtasks to settle
setTimeout(resolve, 100);
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('index.ts enterprise feature wiring', () => {
const originalPlatform = process.platform;
const originalEnvironment = { ...process.env };
beforeEach(() => {
jest.clearAllMocks();
process.env.GITHUB_WORKSPACE = '/workspace';
// Force linux platform so Docker.run is used (not MacBuilder)
Object.defineProperty(process, 'platform', { value: 'linux' });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
process.env = { ...originalEnvironment };
});
// -----------------------------------------------------------------------
// GitHooksService gating
// -----------------------------------------------------------------------
describe('GitHooksService gating', () => {
it('should NOT call GitHooksService when gitHooksEnabled is false (default)', async () => {
await runIndex({ gitHooksEnabled: false });
expect(mockGitHooksService.installHooks).not.toHaveBeenCalled();
expect(mockGitHooksService.configureSkipList).not.toHaveBeenCalled();
});
it('should call installHooks when gitHooksEnabled is true', async () => {
await runIndex({ gitHooksEnabled: true });
expect(mockGitHooksService.installHooks).toHaveBeenCalledWith('/workspace');
});
it('should call configureSkipList when gitHooksEnabled and gitHooksSkipList is set', async () => {
await runIndex({
gitHooksEnabled: true,
gitHooksSkipList: 'pre-commit,pre-push',
});
expect(mockGitHooksService.configureSkipList).toHaveBeenCalledWith(['pre-commit', 'pre-push']);
});
it('should NOT call configureSkipList when gitHooksSkipList is empty', async () => {
await runIndex({
gitHooksEnabled: true,
gitHooksSkipList: '',
});
expect(mockGitHooksService.installHooks).toHaveBeenCalled();
expect(mockGitHooksService.configureSkipList).not.toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// LocalCacheService gating
// -----------------------------------------------------------------------
describe('LocalCacheService gating', () => {
it('should NOT call LocalCacheService when localCacheEnabled is false (default)', async () => {
await runIndex({ localCacheEnabled: false });
expect(mockLocalCacheService.resolveCacheRoot).not.toHaveBeenCalled();
expect(mockLocalCacheService.generateCacheKey).not.toHaveBeenCalled();
expect(mockLocalCacheService.restoreLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).not.toHaveBeenCalled();
});
it('should call restore and save operations when localCacheEnabled is true', async () => {
await runIndex({
localCacheEnabled: true,
localCacheLibrary: true,
localCacheLfs: true,
});
expect(mockLocalCacheService.resolveCacheRoot).toHaveBeenCalled();
expect(mockLocalCacheService.generateCacheKey).toHaveBeenCalled();
expect(mockLocalCacheService.restoreLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).toHaveBeenCalled();
});
it('should only cache Library when localCacheLibrary is true and localCacheLfs is false', async () => {
await runIndex({
localCacheEnabled: true,
localCacheLibrary: true,
localCacheLfs: false,
});
expect(mockLocalCacheService.restoreLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).not.toHaveBeenCalled();
});
it('should only cache LFS when localCacheLfs is true and localCacheLibrary is false', async () => {
await runIndex({
localCacheEnabled: true,
localCacheLibrary: false,
localCacheLfs: true,
});
expect(mockLocalCacheService.restoreLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// ChildWorkspaceService gating
// -----------------------------------------------------------------------
describe('ChildWorkspaceService gating', () => {
it('should NOT call ChildWorkspaceService when childWorkspacesEnabled is false (default)', async () => {
await runIndex({ childWorkspacesEnabled: false });
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
expect(mockChildWorkspaceService.initializeWorkspace).not.toHaveBeenCalled();
expect(mockChildWorkspaceService.saveWorkspace).not.toHaveBeenCalled();
});
it('should NOT call ChildWorkspaceService when childWorkspacesEnabled is true but childWorkspaceName is empty', async () => {
await runIndex({
childWorkspacesEnabled: true,
childWorkspaceName: '',
});
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
});
it('should call buildConfig, initializeWorkspace, and saveWorkspace when enabled with a name', async () => {
mockChildWorkspaceService.buildConfig.mockReturnValue({ enabled: true, workspaceName: 'TurnOfWar' });
await runIndex({
childWorkspacesEnabled: true,
childWorkspaceName: 'TurnOfWar',
childWorkspaceCacheRoot: '/cache/workspaces',
});
expect(mockChildWorkspaceService.buildConfig).toHaveBeenCalledWith(
expect.objectContaining({
childWorkspacesEnabled: true,
childWorkspaceName: 'TurnOfWar',
}),
);
expect(mockChildWorkspaceService.initializeWorkspace).toHaveBeenCalled();
expect(mockChildWorkspaceService.getWorkspaceSize).toHaveBeenCalled();
expect(mockChildWorkspaceService.saveWorkspace).toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// SubmoduleProfileService gating
// -----------------------------------------------------------------------
describe('SubmoduleProfileService gating', () => {
it('should NOT call SubmoduleProfileService when submoduleProfilePath is empty (default)', async () => {
await runIndex({ submoduleProfilePath: '' });
expect(mockSubmoduleProfileService.createInitPlan).not.toHaveBeenCalled();
expect(mockSubmoduleProfileService.execute).not.toHaveBeenCalled();
});
it('should call createInitPlan and execute when submoduleProfilePath is set', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleVariantPath: '',
submoduleToken: 'my-token',
});
expect(mockSubmoduleProfileService.createInitPlan).toHaveBeenCalledWith('/path/to/profile.yml', '', '/workspace');
expect(mockSubmoduleProfileService.execute).toHaveBeenCalled();
});
it('should pass variant path when provided', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleVariantPath: '/path/to/variant.yml',
});
expect(mockSubmoduleProfileService.createInitPlan).toHaveBeenCalledWith(
'/path/to/profile.yml',
'/path/to/variant.yml',
'/workspace',
);
});
it('should use submoduleToken for auth, falling back to gitPrivateToken', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleToken: '',
gitPrivateToken: 'fallback-token',
});
expect(mockSubmoduleProfileService.execute).toHaveBeenCalledWith(
expect.anything(),
'/workspace',
'fallback-token',
);
});
it('should prefer submoduleToken over gitPrivateToken', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleToken: 'specific-token',
gitPrivateToken: 'fallback-token',
});
expect(mockSubmoduleProfileService.execute).toHaveBeenCalledWith(
expect.anything(),
'/workspace',
'specific-token',
);
});
});
// -----------------------------------------------------------------------
// LfsAgentService gating
// -----------------------------------------------------------------------
describe('LfsAgentService gating', () => {
it('should NOT call LfsAgentService when lfsTransferAgent is empty (default)', async () => {
await runIndex({ lfsTransferAgent: '' });
expect(mockLfsAgentService.configure).not.toHaveBeenCalled();
});
it('should call configure when lfsTransferAgent is set', async () => {
await runIndex({
lfsTransferAgent: '/tools/elastic-git-storage',
lfsTransferAgentArgs: '--verbose',
lfsStoragePaths: '/path/a;/path/b',
});
expect(mockLfsAgentService.configure).toHaveBeenCalledWith(
'/tools/elastic-git-storage',
'--verbose',
['/path/a', '/path/b'],
'/workspace',
);
});
it('should pass empty array when lfsStoragePaths is empty', async () => {
await runIndex({
lfsTransferAgent: '/tools/agent',
lfsStoragePaths: '',
});
expect(mockLfsAgentService.configure).toHaveBeenCalledWith('/tools/agent', '', [], '/workspace');
});
});
// -----------------------------------------------------------------------
// Order of operations (restore before build, save after build)
// -----------------------------------------------------------------------
describe('order of operations', () => {
it('should execute restore operations before build and save operations after build', async () => {
const callOrder: string[] = [];
// Track call order for each relevant operation
mockChildWorkspaceService.buildConfig.mockReturnValue({ enabled: true, workspaceName: 'Test' });
mockChildWorkspaceService.initializeWorkspace.mockImplementation(() => {
callOrder.push('child-workspace-restore');
return false;
});
mockChildWorkspaceService.getWorkspaceSize.mockImplementation(() => {
callOrder.push('child-workspace-size');
return '0 B';
});
mockSubmoduleProfileService.createInitPlan.mockImplementation(async () => {
callOrder.push('submodule-profile-plan');
return [];
});
mockSubmoduleProfileService.execute.mockImplementation(async () => {
callOrder.push('submodule-profile-execute');
});
mockLfsAgentService.configure.mockImplementation(async () => {
callOrder.push('lfs-agent-configure');
});
mockLocalCacheService.resolveCacheRoot.mockImplementation(() => {
callOrder.push('local-cache-resolve');
return '/cache';
});
mockLocalCacheService.generateCacheKey.mockImplementation(() => {
callOrder.push('local-cache-keygen');
return 'key-1';
});
mockLocalCacheService.restoreLfsCache.mockImplementation(async () => {
callOrder.push('local-cache-restore-lfs');
return true;
});
mockLocalCacheService.restoreLibraryCache.mockImplementation(async () => {
callOrder.push('local-cache-restore-library');
return true;
});
mockGitHooksService.installHooks.mockImplementation(async () => {
callOrder.push('git-hooks-install');
});
mockLocalCacheService.saveLibraryCache.mockImplementation(async () => {
callOrder.push('local-cache-save-library');
});
mockLocalCacheService.saveLfsCache.mockImplementation(async () => {
callOrder.push('local-cache-save-lfs');
});
mockChildWorkspaceService.saveWorkspace.mockImplementation(() => {
callOrder.push('child-workspace-save');
});
await runIndex({
childWorkspacesEnabled: true,
childWorkspaceName: 'TurnOfWar',
submoduleProfilePath: '/profile.yml',
lfsTransferAgent: '/tools/agent',
localCacheEnabled: true,
localCacheLfs: true,
localCacheLibrary: true,
gitHooksEnabled: true,
});
// Verify restore operations happen before save operations.
// The expected order from index.ts is:
// 1. Child workspace restore
// 2. Submodule profile init
// 3. LFS agent configure
// 4. Local cache restore (LFS then Library)
// 5. Git hooks install
// 6. [BUILD happens here - Docker.run or MacBuilder.run]
// 7. Local cache save (Library then LFS)
// 8. Child workspace save
const restoreOps = [
'child-workspace-restore',
'submodule-profile-plan',
'submodule-profile-execute',
'lfs-agent-configure',
'local-cache-restore-lfs',
'local-cache-restore-library',
'git-hooks-install',
];
const saveOps = ['local-cache-save-library', 'local-cache-save-lfs', 'child-workspace-save'];
// All restore ops should appear before all save ops
for (const restoreOp of restoreOps) {
if (!callOrder.includes(restoreOp)) continue; // Skip if the operation wasn't called
for (const saveOp of saveOps) {
if (!callOrder.includes(saveOp)) continue;
expect(callOrder.indexOf(restoreOp)).toBeLessThan(callOrder.indexOf(saveOp));
}
}
// Child workspace save should be last
if (callOrder.includes('child-workspace-save') && callOrder.includes('local-cache-save-lfs')) {
expect(callOrder.indexOf('local-cache-save-lfs')).toBeLessThan(callOrder.indexOf('child-workspace-save'));
}
});
});
// -----------------------------------------------------------------------
// Non-local provider strategy
// -----------------------------------------------------------------------
describe('non-local provider strategy', () => {
it('should skip all enterprise features when providerStrategy is not local', async () => {
await runIndex({
providerStrategy: 'aws',
childWorkspacesEnabled: true,
childWorkspaceName: 'Test',
submoduleProfilePath: '/profile.yml',
lfsTransferAgent: '/tools/agent',
localCacheEnabled: true,
gitHooksEnabled: true,
});
// None of the enterprise services should be called because
// they are inside the `if (providerStrategy === 'local')` block
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
expect(mockSubmoduleProfileService.createInitPlan).not.toHaveBeenCalled();
expect(mockLfsAgentService.configure).not.toHaveBeenCalled();
expect(mockLocalCacheService.resolveCacheRoot).not.toHaveBeenCalled();
expect(mockGitHooksService.installHooks).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,7 +4,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';
import { TestWorkflowService } from './model/orchestrator/services/test-workflow';
import { HotRunnerService } from './model/orchestrator/services/hot-runner';
import { HotRunnerConfig } from './model/orchestrator/services/hot-runner/hot-runner-types';
@@ -24,9 +23,6 @@ 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();
@@ -49,30 +45,6 @@ async function runMain() {
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;
// Hot runner path: attempt to use a persistent Unity editor instance
@@ -115,93 +87,6 @@ async function runMain() {
} else if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
// Child workspace isolation - restore cached workspace before any other setup
let childWorkspaceConfig: any;
if (buildParameters.childWorkspacesEnabled && buildParameters.childWorkspaceName) {
const { ChildWorkspaceService } = await import('./model/orchestrator/services/cache/child-workspace-service');
const cacheRoot =
buildParameters.childWorkspaceCacheRoot ||
path.join(buildParameters.runnerTempPath || process.env.RUNNER_TEMP || '', 'game-ci-workspaces');
childWorkspaceConfig = ChildWorkspaceService.buildConfig({
childWorkspacesEnabled: buildParameters.childWorkspacesEnabled,
childWorkspaceName: buildParameters.childWorkspaceName,
childWorkspaceCacheRoot: cacheRoot,
childWorkspacePreserveGit: buildParameters.childWorkspacePreserveGit,
childWorkspaceSeparateLibrary: buildParameters.childWorkspaceSeparateLibrary,
});
const projectFullPath = path.join(workspace, buildParameters.projectPath);
const restored = ChildWorkspaceService.initializeWorkspace(projectFullPath, childWorkspaceConfig);
core.info(
`Child workspace "${buildParameters.childWorkspaceName}": ${
restored ? 'restored from cache' : 'starting fresh'
}`,
);
// Log workspace size for resource tracking
const size = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
core.info(`Child workspace size after restore: ${size}`);
}
// Submodule profile initialization
if (buildParameters.submoduleProfilePath) {
const { SubmoduleProfileService } = await import(
'./model/orchestrator/services/submodule/submodule-profile-service'
);
core.info('Initializing submodules from profile...');
const plan = await SubmoduleProfileService.createInitPlan(
buildParameters.submoduleProfilePath,
buildParameters.submoduleVariantPath,
workspace,
);
await SubmoduleProfileService.execute(
plan,
workspace,
buildParameters.submoduleToken || buildParameters.gitPrivateToken,
);
}
// Configure custom LFS transfer agent
if (buildParameters.lfsTransferAgent) {
const { LfsAgentService } = await import('./model/orchestrator/services/lfs/lfs-agent-service');
core.info('Configuring custom LFS transfer agent...');
await LfsAgentService.configure(
buildParameters.lfsTransferAgent,
buildParameters.lfsTransferAgentArgs,
buildParameters.lfsStoragePaths ? buildParameters.lfsStoragePaths.split(';') : [],
workspace,
);
}
// Local build caching - restore
let cacheRoot = '';
let cacheKey = '';
if (buildParameters.localCacheEnabled) {
const { LocalCacheService } = await import('./model/orchestrator/services/cache/local-cache-service');
cacheRoot = LocalCacheService.resolveCacheRoot(buildParameters);
cacheKey = LocalCacheService.generateCacheKey(
buildParameters.targetPlatform,
buildParameters.editorVersion,
buildParameters.branch || '',
);
if (buildParameters.localCacheLfs) {
await LocalCacheService.restoreLfsCache(workspace, cacheRoot, cacheKey);
}
if (buildParameters.localCacheLibrary) {
const projectFullPath = path.join(workspace, buildParameters.projectPath);
await LocalCacheService.restoreLibraryCache(projectFullPath, cacheRoot, cacheKey);
}
}
// Git hooks — opt-in only. When disabled (default), do not touch hooks at all.
if (buildParameters.gitHooksEnabled) {
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
await GitHooksService.installHooks(workspace);
if (buildParameters.gitHooksSkipList) {
const environment = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
Object.assign(process.env, environment);
}
}
// Apply incremental sync strategy before build
const syncStrategy = buildParameters.syncStrategy as SyncStrategy;
if (syncStrategy !== 'full') {
@@ -209,38 +94,7 @@ async function runMain() {
await applySyncStrategy(buildParameters, workspace);
}
await PlatformSetup.setup(buildParameters, actionFolder);
exitCode =
process.platform === 'darwin'
? await MacBuilder.run(actionFolder)
: await Docker.run(baseImage.toString(), {
workspace,
actionFolder,
...buildParameters,
});
// Local build caching - save
if (buildParameters.localCacheEnabled) {
const { LocalCacheService } = await import('./model/orchestrator/services/cache/local-cache-service');
if (buildParameters.localCacheLibrary) {
const projectFullPath = path.join(workspace, buildParameters.projectPath);
await LocalCacheService.saveLibraryCache(projectFullPath, cacheRoot, cacheKey);
}
if (buildParameters.localCacheLfs) {
await LocalCacheService.saveLfsCache(workspace, cacheRoot, cacheKey);
}
}
// Child workspace isolation - save workspace for next run
if (childWorkspaceConfig && childWorkspaceConfig.enabled) {
const { ChildWorkspaceService } = await import('./model/orchestrator/services/cache/child-workspace-service');
const projectFullPath = path.join(workspace, buildParameters.projectPath);
const preSaveSize = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
core.info(`Child workspace size before save: ${preSaveSize}`);
ChildWorkspaceService.saveWorkspace(projectFullPath, childWorkspaceConfig);
core.info(`Child workspace "${buildParameters.childWorkspaceName}" saved to cache`);
}
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
// Revert overlays after job completion if configured
if (buildParameters.syncRevertAfter && syncStrategy !== 'full') {
@@ -251,19 +105,11 @@ async function runMain() {
core.warning(`[Sync] Overlay revert failed: ${(revertError as Error).message}`);
}
}
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
} else {
await Orchestrator.run(buildParameters, baseImage.toString());
exitCode = 0;
}
// Post-build: archive and enforce retention
if (buildParameters.buildArchiveEnabled && exitCode === 0) {
core.info('Archiving build output...');
BuildReliabilityService.archiveBuildOutput(buildParameters.buildPath, buildParameters.buildArchivePath);
BuildReliabilityService.enforceRetention(buildParameters.buildArchivePath, buildParameters.buildArchiveRetention);
}
// Set output
await Output.setBuildVersion(buildParameters.buildVersion);
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);

View File

@@ -54,13 +54,6 @@ class BuildParameters {
public sshAgent!: string;
public sshPublicKeysDirectoryPath!: string;
public providerStrategy!: string;
public fallbackProviderStrategy!: string;
public runnerCheckEnabled!: boolean;
public runnerCheckLabels!: string[];
public runnerCheckMinAvailable!: number;
public retryOnFallback!: boolean;
public providerInitTimeout!: number;
public gitAuthMode!: string;
public gitPrivateToken!: string;
public awsStackName!: string;
public awsEndpoint?: string;
@@ -113,85 +106,6 @@ class BuildParameters {
public cacheUnityInstallationOnMac!: boolean;
public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: string;
public submoduleProfilePath!: string;
public submoduleVariantPath!: string;
public submoduleToken!: string;
public localCacheEnabled!: boolean;
public localCacheRoot!: string;
public localCacheLibrary!: boolean;
public localCacheLfs!: boolean;
public childWorkspacesEnabled!: boolean;
public childWorkspaceName!: string;
public childWorkspaceCacheRoot!: string;
public childWorkspacePreserveGit!: boolean;
public childWorkspaceSeparateLibrary!: boolean;
public lfsTransferAgent!: string;
public lfsTransferAgentArgs!: string;
public lfsStoragePaths!: string;
public gitHooksEnabled!: boolean;
public gitHooksSkipList!: string;
public gitHooksRunBeforeBuild!: string;
public providerExecutable!: string;
// GCP Cloud Run (Experimental)
public gcpProject!: string;
public gcpRegion!: string;
public gcpStorageType!: string;
public gcpBucket!: string;
public gcpFilestoreIp!: string;
public gcpFilestoreShare!: string;
public gcpMachineType!: string;
public gcpDiskSizeGb!: string;
public gcpServiceAccount!: string;
public gcpVpcConnector!: string;
// Azure Container Instances (Experimental)
public azureResourceGroup!: string;
public azureLocation!: string;
public azureStorageType!: string;
public azureStorageAccount!: string;
public azureBlobContainer!: string;
public azureFileShareName!: string;
public azureSubscriptionId!: string;
public azureCpu!: string;
public azureMemoryGb!: string;
public azureDiskSizeGb!: string;
public azureSubnetId!: string;
// Remote PowerShell provider
public remotePowershellHost!: string;
public remotePowershellCredential!: string;
public remotePowershellTransport!: string;
// GitHub Actions provider
public githubActionsRepo!: string;
public githubActionsWorkflow!: string;
public githubActionsToken!: string;
public githubActionsRef!: string;
// GitLab CI provider
public gitlabProjectId!: string;
public gitlabTriggerToken!: string;
public gitlabApiUrl!: string;
public gitlabRef!: string;
// Ansible provider
public ansibleInventory!: string;
public ansiblePlaybook!: string;
public ansibleExtraVars!: string;
public ansibleVaultPassword!: string;
public gitIntegrityCheck!: boolean;
public gitAutoRecover!: boolean;
public cleanReservedFilenames!: boolean;
public buildArchiveEnabled!: boolean;
public buildArchivePath!: string;
public buildArchiveRetention!: number;
public testSuitePath!: string;
public testSuiteEvent!: string;
public testTaxonomyPath!: string;
public testResultFormat!: string;
public testResultPath!: string;
public hotRunnerEnabled!: boolean;
public hotRunnerTransport!: 'websocket' | 'grpc' | 'named-pipe';
public hotRunnerHost!: string;
@@ -199,12 +113,20 @@ class BuildParameters {
public hotRunnerHealthInterval!: number;
public hotRunnerMaxIdle!: number;
public hotRunnerFallbackToCold!: boolean;
public testSuitePath!: string;
public testSuiteEvent!: string;
public testTaxonomyPath!: string;
public testResultFormat!: string;
public testResultPath!: string;
public artifactOutputTypes!: string;
public artifactUploadTarget!: string;
public artifactUploadPath!: string;
public artifactCompression!: string;
public artifactRetentionDays!: string;
public artifactCustomTypes!: string;
public syncStrategy!: string;
public syncInputRef!: string;
public syncStorageRemote!: string;
@@ -298,13 +220,6 @@ class BuildParameters {
containerRegistryRepository: Input.containerRegistryRepository,
containerRegistryImageVersion: Input.containerRegistryImageVersion,
providerStrategy: OrchestratorOptions.providerStrategy,
fallbackProviderStrategy: OrchestratorOptions.fallbackProviderStrategy,
runnerCheckEnabled: OrchestratorOptions.runnerCheckEnabled,
runnerCheckLabels: OrchestratorOptions.runnerCheckLabels,
runnerCheckMinAvailable: OrchestratorOptions.runnerCheckMinAvailable,
retryOnFallback: OrchestratorOptions.retryOnFallback,
providerInitTimeout: OrchestratorOptions.providerInitTimeout,
gitAuthMode: OrchestratorOptions.gitAuthMode,
buildPlatform: OrchestratorOptions.buildPlatform,
kubeConfig: OrchestratorOptions.kubeConfig,
containerMemory: OrchestratorOptions.containerMemory,
@@ -339,27 +254,6 @@ class BuildParameters {
inputPullCommand: OrchestratorOptions.inputPullCommand,
pullInputList: OrchestratorOptions.pullInputList,
kubeStorageClass: OrchestratorOptions.kubeStorageClass,
gcpProject: Input.gcpProject,
gcpRegion: Input.gcpRegion,
gcpStorageType: Input.gcpStorageType,
gcpBucket: Input.gcpBucket,
gcpFilestoreIp: Input.gcpFilestoreIp,
gcpFilestoreShare: Input.gcpFilestoreShare,
gcpMachineType: Input.gcpMachineType,
gcpDiskSizeGb: Input.gcpDiskSizeGb,
gcpServiceAccount: Input.gcpServiceAccount,
gcpVpcConnector: Input.gcpVpcConnector,
azureResourceGroup: Input.azureResourceGroup,
azureLocation: Input.azureLocation,
azureStorageType: Input.azureStorageType,
azureStorageAccount: Input.azureStorageAccount,
azureBlobContainer: Input.azureBlobContainer,
azureFileShareName: Input.azureFileShareName,
azureSubscriptionId: Input.azureSubscriptionId,
azureCpu: Input.azureCpu,
azureMemoryGb: Input.azureMemoryGb,
azureDiskSizeGb: Input.azureDiskSizeGb,
azureSubnetId: Input.azureSubnetId,
cacheKey: OrchestratorOptions.cacheKey,
maxRetainedWorkspaces: Number.parseInt(OrchestratorOptions.maxRetainedWorkspaces),
useLargePackages: OrchestratorOptions.useLargePackages,
@@ -374,54 +268,6 @@ class BuildParameters {
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath,
submoduleProfilePath: Input.submoduleProfilePath,
submoduleVariantPath: Input.submoduleVariantPath,
submoduleToken: Input.submoduleToken,
localCacheEnabled: Input.localCacheEnabled,
localCacheRoot: Input.localCacheRoot,
localCacheLibrary: Input.localCacheLibrary,
localCacheLfs: Input.localCacheLfs,
childWorkspacesEnabled: Input.childWorkspacesEnabled,
childWorkspaceName: Input.childWorkspaceName,
childWorkspaceCacheRoot: Input.childWorkspaceCacheRoot,
childWorkspacePreserveGit: Input.childWorkspacePreserveGit,
childWorkspaceSeparateLibrary: Input.childWorkspaceSeparateLibrary,
lfsTransferAgent: Input.lfsTransferAgent,
lfsTransferAgentArgs: Input.lfsTransferAgentArgs,
lfsStoragePaths: Input.lfsStoragePaths,
gitHooksEnabled: Input.gitHooksEnabled,
gitHooksSkipList: Input.gitHooksSkipList,
gitHooksRunBeforeBuild: Input.gitHooksRunBeforeBuild,
providerExecutable: Input.providerExecutable,
// Remote PowerShell provider
remotePowershellHost: Input.remotePowershellHost,
remotePowershellCredential: Input.remotePowershellCredential,
remotePowershellTransport: Input.remotePowershellTransport,
// GitHub Actions provider
githubActionsRepo: Input.githubActionsRepo,
githubActionsWorkflow: Input.githubActionsWorkflow,
githubActionsToken: Input.githubActionsToken,
githubActionsRef: Input.githubActionsRef,
// GitLab CI provider
gitlabProjectId: Input.gitlabProjectId,
gitlabTriggerToken: Input.gitlabTriggerToken,
gitlabApiUrl: Input.gitlabApiUrl,
gitlabRef: Input.gitlabRef,
// Ansible provider
ansibleInventory: Input.ansibleInventory,
ansiblePlaybook: Input.ansiblePlaybook,
ansibleExtraVars: Input.ansibleExtraVars,
ansibleVaultPassword: Input.ansibleVaultPassword,
gitIntegrityCheck: Input.gitIntegrityCheck,
gitAutoRecover: Input.gitAutoRecover,
cleanReservedFilenames: Input.cleanReservedFilenames,
buildArchiveEnabled: Input.buildArchiveEnabled,
buildArchivePath: Input.buildArchivePath,
buildArchiveRetention: Input.buildArchiveRetention,
testSuitePath: Input.testSuitePath,
testSuiteEvent: Input.testSuiteEvent,
testTaxonomyPath: Input.testTaxonomyPath,

View File

@@ -12,8 +12,6 @@ import OrchestratorOptionsReader from '../orchestrator/options/orchestrator-opti
import GitHub from '../github';
import { OptionValues } from 'commander';
import { InputKey } from '../input';
import { SubmoduleProfileService } from '../orchestrator/services/submodule/submodule-profile-service';
import { LfsAgentService } from '../orchestrator/services/lfs/lfs-agent-service';
export class Cli {
public static options: OptionValues | undefined;
@@ -55,11 +53,6 @@ export class Cli {
program.option('--artifactName <artifactName>', 'caching artifact name');
program.option('--select <select>', 'select a particular resource');
program.option('--logFile <logFile>', 'output to log file (log stream only)');
program.option('--profilePath <profilePath>', 'path to submodule profile YAML');
program.option('--variantPath <variantPath>', 'path to submodule variant YAML');
program.option('--agentPath <agentPath>', 'path to custom LFS transfer agent');
program.option('--agentArgs <agentArgs>', 'arguments for custom LFS transfer agent');
program.option('--storagePaths <storagePaths>', 'semicolon-separated storage paths for LFS agent');
program.parse(process.argv);
Cli.options = program.opts();
@@ -179,26 +172,4 @@ export class Cli {
return await Orchestrator.Provider.watchWorkflow();
}
@CliFunction(`submodule-init`, `initializes submodules from a YAML profile`)
public static async SubmoduleInit(): Promise<void> {
const profilePath = Cli.options!['profilePath'];
const variantPath = Cli.options!['variantPath'] || '';
if (!profilePath) {
throw new Error('--profilePath is required for submodule-init');
}
const plan = await SubmoduleProfileService.createInitPlan(profilePath, variantPath, process.cwd());
await SubmoduleProfileService.execute(plan, process.cwd());
}
@CliFunction(`lfs-agent-configure`, `configures a custom LFS transfer agent`)
public static async LfsAgentConfigure(): Promise<void> {
const agentPath = Cli.options!['agentPath'];
if (!agentPath) {
throw new Error('--agentPath is required for lfs-agent-configure');
}
const agentArgs = Cli.options!['agentArgs'] || '';
const storagePaths = (Cli.options!['storagePaths'] || '').split(';').filter(Boolean);
await LfsAgentService.configure(agentPath, agentArgs, storagePaths, process.cwd());
}
}

View File

@@ -1,509 +0,0 @@
/**
* Tests for enterprise input properties and their wiring into BuildParameters.
*
* Covers all 20 new input properties added for enterprise features:
* - Boolean inputs: localCacheEnabled, childWorkspacesEnabled, gitHooksEnabled,
* localCacheLibrary, localCacheLfs, childWorkspacePreserveGit, childWorkspaceSeparateLibrary
* - String inputs: submoduleProfilePath, submoduleVariantPath, submoduleToken,
* localCacheRoot, childWorkspaceName, childWorkspaceCacheRoot, lfsTransferAgent,
* lfsTransferAgentArgs, lfsStoragePaths, providerExecutable, gitHooksSkipList,
* gitHooksRunBeforeBuild
*
* Special attention to boolean inputs: GitHub Actions always passes inputs as strings,
* so 'false' must NOT evaluate as truthy (the #1 source of bugs).
*/
import * as core from '@actions/core';
import Input from './input';
import Versioning from './versioning';
import BuildParameters from './build-parameters';
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
afterEach(() => {
jest.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Part 1: Input getters — defaults and explicit values
// ---------------------------------------------------------------------------
describe('Enterprise Input properties', () => {
// -----------------------------------------------------------------------
// Boolean inputs — default and string parsing
// -----------------------------------------------------------------------
describe('localCacheEnabled', () => {
it('returns false by default', () => {
expect(Input.localCacheEnabled).toBe(false);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.localCacheEnabled).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.localCacheEnabled).toBe(false);
});
it('returns false when empty string is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('');
expect(Input.localCacheEnabled).toBe(false);
});
});
describe('localCacheLibrary', () => {
it('returns true by default (library caching on by default when cache enabled)', () => {
expect(Input.localCacheLibrary).toBe(true);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.localCacheLibrary).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.localCacheLibrary).toBe(false);
});
});
describe('localCacheLfs', () => {
it('returns false by default', () => {
expect(Input.localCacheLfs).toBe(false);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.localCacheLfs).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.localCacheLfs).toBe(false);
});
});
describe('childWorkspacesEnabled', () => {
it('returns false by default', () => {
expect(Input.childWorkspacesEnabled).toBe(false);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.childWorkspacesEnabled).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.childWorkspacesEnabled).toBe(false);
});
it('returns false when empty string is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('');
expect(Input.childWorkspacesEnabled).toBe(false);
});
});
describe('childWorkspacePreserveGit', () => {
it('returns true by default', () => {
expect(Input.childWorkspacePreserveGit).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.childWorkspacePreserveGit).toBe(false);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.childWorkspacePreserveGit).toBe(true);
});
});
describe('childWorkspaceSeparateLibrary', () => {
it('returns true by default', () => {
expect(Input.childWorkspaceSeparateLibrary).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.childWorkspaceSeparateLibrary).toBe(false);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.childWorkspaceSeparateLibrary).toBe(true);
});
});
describe('gitHooksEnabled', () => {
it('returns false by default', () => {
expect(Input.gitHooksEnabled).toBe(false);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.gitHooksEnabled).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.gitHooksEnabled).toBe(false);
});
it('returns false when empty string is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('');
expect(Input.gitHooksEnabled).toBe(false);
});
});
// -----------------------------------------------------------------------
// Boolean truthiness edge cases — the #1 source of bugs
// -----------------------------------------------------------------------
describe('boolean input string handling (edge cases)', () => {
// These tests verify that the === 'true' comparison is correct.
// In JavaScript, 'false' is truthy when used in a boolean context,
// but the Input class correctly uses === 'true' comparison.
const booleanInputs: Array<{
name: string;
getter: () => boolean;
defaultValue: boolean;
}> = [
{ name: 'localCacheEnabled', getter: () => Input.localCacheEnabled, defaultValue: false },
{ name: 'localCacheLfs', getter: () => Input.localCacheLfs, defaultValue: false },
{ name: 'childWorkspacesEnabled', getter: () => Input.childWorkspacesEnabled, defaultValue: false },
{ name: 'gitHooksEnabled', getter: () => Input.gitHooksEnabled, defaultValue: false },
// These default to true:
{ name: 'localCacheLibrary', getter: () => Input.localCacheLibrary, defaultValue: true },
{ name: 'childWorkspacePreserveGit', getter: () => Input.childWorkspacePreserveGit, defaultValue: true },
{ name: 'childWorkspaceSeparateLibrary', getter: () => Input.childWorkspaceSeparateLibrary, defaultValue: true },
];
test.each(booleanInputs)('$name: "false" string does NOT evaluate as truthy', ({ getter }) => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(getter()).toBe(false);
});
test.each(booleanInputs)('$name: "true" string evaluates as truthy', ({ getter }) => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(getter()).toBe(true);
});
test.each(booleanInputs)('$name: "TRUE" (uppercase) does NOT evaluate as true (case sensitive)', ({ getter }) => {
jest.spyOn(core, 'getInput').mockReturnValue('TRUE');
expect(getter()).toBe(false);
});
test.each(booleanInputs)('$name: "1" does NOT evaluate as true', ({ getter }) => {
jest.spyOn(core, 'getInput').mockReturnValue('1');
expect(getter()).toBe(false);
});
test.each(booleanInputs)('$name: "yes" does NOT evaluate as true', ({ getter }) => {
jest.spyOn(core, 'getInput').mockReturnValue('yes');
expect(getter()).toBe(false);
});
});
// -----------------------------------------------------------------------
// String inputs — defaults and explicit values
// -----------------------------------------------------------------------
describe('submoduleProfilePath', () => {
it('returns empty string by default', () => {
expect(Input.submoduleProfilePath).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('config/submodule-profiles/tow/ec/profile.yml');
expect(Input.submoduleProfilePath).toBe('config/submodule-profiles/tow/ec/profile.yml');
});
});
describe('submoduleVariantPath', () => {
it('returns empty string by default', () => {
expect(Input.submoduleVariantPath).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('config/submodule-profiles/tow/ec/server.yml');
expect(Input.submoduleVariantPath).toBe('config/submodule-profiles/tow/ec/server.yml');
});
});
describe('submoduleToken', () => {
it('returns empty string by default', () => {
expect(Input.submoduleToken).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('ghp_abc123');
expect(Input.submoduleToken).toBe('ghp_abc123');
});
});
describe('localCacheRoot', () => {
it('returns empty string by default', () => {
expect(Input.localCacheRoot).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('/d/cache/game-ci');
expect(Input.localCacheRoot).toBe('/d/cache/game-ci');
});
});
describe('childWorkspaceName', () => {
it('returns empty string by default', () => {
expect(Input.childWorkspaceName).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('TurnOfWarEndlessCrusade');
expect(Input.childWorkspaceName).toBe('TurnOfWarEndlessCrusade');
});
});
describe('childWorkspaceCacheRoot', () => {
it('returns empty string by default', () => {
expect(Input.childWorkspaceCacheRoot).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('/d/workspaces');
expect(Input.childWorkspaceCacheRoot).toBe('/d/workspaces');
});
});
describe('lfsTransferAgent', () => {
it('returns empty string by default', () => {
expect(Input.lfsTransferAgent).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('/tools/elastic-git-storage');
expect(Input.lfsTransferAgent).toBe('/tools/elastic-git-storage');
});
});
describe('lfsTransferAgentArgs', () => {
it('returns empty string by default', () => {
expect(Input.lfsTransferAgentArgs).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('--verbose --timeout=60');
expect(Input.lfsTransferAgentArgs).toBe('--verbose --timeout=60');
});
});
describe('lfsStoragePaths', () => {
it('returns empty string by default', () => {
expect(Input.lfsStoragePaths).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('/storage/primary;/storage/secondary');
expect(Input.lfsStoragePaths).toBe('/storage/primary;/storage/secondary');
});
});
describe('providerExecutable', () => {
it('returns empty string by default', () => {
expect(Input.providerExecutable).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('/usr/local/bin/custom-provider');
expect(Input.providerExecutable).toBe('/usr/local/bin/custom-provider');
});
});
describe('gitHooksSkipList', () => {
it('returns empty string by default', () => {
expect(Input.gitHooksSkipList).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('pre-commit,pre-push');
expect(Input.gitHooksSkipList).toBe('pre-commit,pre-push');
});
});
describe('gitHooksRunBeforeBuild', () => {
it('returns empty string by default', () => {
expect(Input.gitHooksRunBeforeBuild).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('pre-commit');
expect(Input.gitHooksRunBeforeBuild).toBe('pre-commit');
});
});
});
// ---------------------------------------------------------------------------
// Part 2: BuildParameters.create() maps new inputs to properties
// ---------------------------------------------------------------------------
const testLicense =
'<?xml version="1.0" encoding="UTF-8"?><root>\n <License id="Terms">\n <MachineBindings>\n <Binding Key="1" Value="576562626572264761624c65526f7578"/>\n <Binding Key="2" Value="576562626572264761624c65526f7578"/>\n </MachineBindings>\n <MachineID Value="D7nTUnjNAmtsUMcnoyrqkgIbYdM="/>\n <SerialHash Value="2033b8ac3e6faa3742ca9f0bfae44d18f2a96b80"/>\n <Features>\n <Feature Value="33"/>\n <Feature Value="1"/>\n <Feature Value="12"/>\n <Feature Value="2"/>\n <Feature Value="24"/>\n <Feature Value="3"/>\n <Feature Value="36"/>\n <Feature Value="17"/>\n <Feature Value="19"/>\n <Feature Value="62"/>\n </Features>\n <DeveloperData Value="AQAAAEY0LUJHUlgtWEQ0RS1aQ1dWLUM1SlctR0RIQg=="/>\n <SerialMasked Value="F4-BGRX-XD4E-ZCWV-C5JW-XXXX"/>\n <StartDate Value="2021-02-08T00:00:00"/>\n <UpdateDate Value="2021-02-09T00:34:57"/>\n <InitialActivationDate Value="2021-02-08T00:34:56"/>\n <LicenseVersion Value="6.x"/>\n <ClientProvidedVersion Value="2018.4.30f1"/>\n <AlwaysOnline Value="false"/>\n <Entitlements>\n <Entitlement Ns="unity_editor" Tag="UnityPersonal" Type="EDITOR" ValidTo="9999-12-31T00:00:00"/>\n <Entitlement Ns="unity_editor" Tag="DarkSkin" Type="EDITOR_FEATURE" ValidTo="9999-12-31T00:00:00"/>\n </Entitlements>\n </License>\n<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#Terms"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>m0Db8UK+ktnOLJBtHybkfetpcKo=</DigestValue></Reference></SignedInfo><SignatureValue>o/pUbSQAukz7+ZYAWhnA0AJbIlyyCPL7bKVEM2lVqbrXt7cyey+umkCXamuOgsWPVUKBMkXtMH8L\n5etLmD0getWIhTGhzOnDCk+gtIPfL4jMo9tkEuOCROQAXCci23VFscKcrkB+3X6h4wEOtA2APhOY\nB+wvC794o8/82ffjP79aVAi57rp3Wmzx+9pe9yMwoJuljAy2sc2tIMgdQGWVmOGBpQm3JqsidyzI\nJWG2kjnc7pDXK9pwYzXoKiqUqqrut90d+kQqRyv7MSZXR50HFqD/LI69h68b7P8Bjo3bPXOhNXGR\n9YCoemH6EkfCJxp2gIjzjWW+l2Hj2EsFQi8YXw==</SignatureValue></Signature></root>';
describe('BuildParameters.create() enterprise property mapping', () => {
beforeEach(() => {
jest.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37');
process.env.UNITY_LICENSE = testLicense;
});
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('maps submoduleProfilePath from Input', async () => {
jest.spyOn(Input, 'submoduleProfilePath', 'get').mockReturnValue('/path/to/profile.yml');
const parameters = await BuildParameters.create();
expect(parameters.submoduleProfilePath).toBe('/path/to/profile.yml');
});
it('maps submoduleVariantPath from Input', async () => {
jest.spyOn(Input, 'submoduleVariantPath', 'get').mockReturnValue('/path/to/variant.yml');
const parameters = await BuildParameters.create();
expect(parameters.submoduleVariantPath).toBe('/path/to/variant.yml');
});
it('maps submoduleToken from Input', async () => {
jest.spyOn(Input, 'submoduleToken', 'get').mockReturnValue('ghp_token123');
const parameters = await BuildParameters.create();
expect(parameters.submoduleToken).toBe('ghp_token123');
});
it('maps localCacheEnabled from Input', async () => {
jest.spyOn(Input, 'localCacheEnabled', 'get').mockReturnValue(true);
const parameters = await BuildParameters.create();
expect(parameters.localCacheEnabled).toBe(true);
});
it('maps localCacheRoot from Input', async () => {
jest.spyOn(Input, 'localCacheRoot', 'get').mockReturnValue('/d/cache');
const parameters = await BuildParameters.create();
expect(parameters.localCacheRoot).toBe('/d/cache');
});
it('maps localCacheLibrary from Input', async () => {
jest.spyOn(Input, 'localCacheLibrary', 'get').mockReturnValue(false);
const parameters = await BuildParameters.create();
expect(parameters.localCacheLibrary).toBe(false);
});
it('maps localCacheLfs from Input', async () => {
jest.spyOn(Input, 'localCacheLfs', 'get').mockReturnValue(true);
const parameters = await BuildParameters.create();
expect(parameters.localCacheLfs).toBe(true);
});
it('maps childWorkspacesEnabled from Input', async () => {
jest.spyOn(Input, 'childWorkspacesEnabled', 'get').mockReturnValue(true);
const parameters = await BuildParameters.create();
expect(parameters.childWorkspacesEnabled).toBe(true);
});
it('maps childWorkspaceName from Input', async () => {
jest.spyOn(Input, 'childWorkspaceName', 'get').mockReturnValue('TurnOfWar');
const parameters = await BuildParameters.create();
expect(parameters.childWorkspaceName).toBe('TurnOfWar');
});
it('maps childWorkspaceCacheRoot from Input', async () => {
jest.spyOn(Input, 'childWorkspaceCacheRoot', 'get').mockReturnValue('/cache/workspaces');
const parameters = await BuildParameters.create();
expect(parameters.childWorkspaceCacheRoot).toBe('/cache/workspaces');
});
it('maps childWorkspacePreserveGit from Input', async () => {
jest.spyOn(Input, 'childWorkspacePreserveGit', 'get').mockReturnValue(false);
const parameters = await BuildParameters.create();
expect(parameters.childWorkspacePreserveGit).toBe(false);
});
it('maps childWorkspaceSeparateLibrary from Input', async () => {
jest.spyOn(Input, 'childWorkspaceSeparateLibrary', 'get').mockReturnValue(false);
const parameters = await BuildParameters.create();
expect(parameters.childWorkspaceSeparateLibrary).toBe(false);
});
it('maps lfsTransferAgent from Input', async () => {
jest.spyOn(Input, 'lfsTransferAgent', 'get').mockReturnValue('/tools/elastic-git-storage');
const parameters = await BuildParameters.create();
expect(parameters.lfsTransferAgent).toBe('/tools/elastic-git-storage');
});
it('maps lfsTransferAgentArgs from Input', async () => {
jest.spyOn(Input, 'lfsTransferAgentArgs', 'get').mockReturnValue('--verbose');
const parameters = await BuildParameters.create();
expect(parameters.lfsTransferAgentArgs).toBe('--verbose');
});
it('maps lfsStoragePaths from Input', async () => {
jest.spyOn(Input, 'lfsStoragePaths', 'get').mockReturnValue('/path/a;/path/b');
const parameters = await BuildParameters.create();
expect(parameters.lfsStoragePaths).toBe('/path/a;/path/b');
});
it('maps gitHooksEnabled from Input', async () => {
jest.spyOn(Input, 'gitHooksEnabled', 'get').mockReturnValue(true);
const parameters = await BuildParameters.create();
expect(parameters.gitHooksEnabled).toBe(true);
});
it('maps gitHooksSkipList from Input', async () => {
jest.spyOn(Input, 'gitHooksSkipList', 'get').mockReturnValue('pre-commit,pre-push');
const parameters = await BuildParameters.create();
expect(parameters.gitHooksSkipList).toBe('pre-commit,pre-push');
});
it('maps gitHooksRunBeforeBuild from Input', async () => {
jest.spyOn(Input, 'gitHooksRunBeforeBuild', 'get').mockReturnValue('pre-commit');
const parameters = await BuildParameters.create();
expect(parameters.gitHooksRunBeforeBuild).toBe('pre-commit');
});
it('maps providerExecutable from Input', async () => {
jest.spyOn(Input, 'providerExecutable', 'get').mockReturnValue('/usr/local/bin/provider');
const parameters = await BuildParameters.create();
expect(parameters.providerExecutable).toBe('/usr/local/bin/provider');
});
// Test that all enterprise properties have correct defaults when not explicitly set
it('has correct defaults for all enterprise properties', async () => {
const parameters = await BuildParameters.create();
expect(parameters.submoduleProfilePath).toBe('');
expect(parameters.submoduleVariantPath).toBe('');
expect(parameters.submoduleToken).toBe('');
expect(parameters.localCacheEnabled).toBe(false);
expect(parameters.localCacheRoot).toBe('');
expect(parameters.localCacheLibrary).toBe(true);
expect(parameters.localCacheLfs).toBe(false);
expect(parameters.childWorkspacesEnabled).toBe(false);
expect(parameters.childWorkspaceName).toBe('');
expect(parameters.childWorkspaceCacheRoot).toBe('');
expect(parameters.childWorkspacePreserveGit).toBe(true);
expect(parameters.childWorkspaceSeparateLibrary).toBe(true);
expect(parameters.lfsTransferAgent).toBe('');
expect(parameters.lfsTransferAgentArgs).toBe('');
expect(parameters.lfsStoragePaths).toBe('');
expect(parameters.gitHooksEnabled).toBe(false);
expect(parameters.gitHooksSkipList).toBe('');
expect(parameters.gitHooksRunBeforeBuild).toBe('');
expect(parameters.providerExecutable).toBe('');
});
});

View File

@@ -328,248 +328,24 @@ class Input {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
static get submoduleProfilePath(): string {
return Input.getInput('submoduleProfilePath') ?? '';
static get testSuitePath(): string {
return Input.getInput('testSuitePath') ?? '';
}
static get submoduleVariantPath(): string {
return Input.getInput('submoduleVariantPath') ?? '';
static get testSuiteEvent(): string {
return Input.getInput('testSuiteEvent') ?? '';
}
static get submoduleToken(): string {
return Input.getInput('submoduleToken') ?? '';
static get testTaxonomyPath(): string {
return Input.getInput('testTaxonomyPath') ?? '';
}
static get localCacheEnabled(): boolean {
return (Input.getInput('localCacheEnabled') ?? 'false') === 'true';
static get testResultFormat(): string {
return Input.getInput('testResultFormat') ?? 'junit';
}
static get localCacheRoot(): string {
return Input.getInput('localCacheRoot') ?? '';
}
static get localCacheLibrary(): boolean {
return (Input.getInput('localCacheLibrary') ?? 'true') === 'true';
}
static get localCacheLfs(): boolean {
return (Input.getInput('localCacheLfs') ?? 'false') === 'true';
}
static get childWorkspacesEnabled(): boolean {
return (Input.getInput('childWorkspacesEnabled') ?? 'false') === 'true';
}
static get childWorkspaceName(): string {
return Input.getInput('childWorkspaceName') ?? '';
}
static get childWorkspaceCacheRoot(): string {
return Input.getInput('childWorkspaceCacheRoot') ?? '';
}
static get childWorkspacePreserveGit(): boolean {
return (Input.getInput('childWorkspacePreserveGit') ?? 'true') === 'true';
}
static get childWorkspaceSeparateLibrary(): boolean {
return (Input.getInput('childWorkspaceSeparateLibrary') ?? 'true') === 'true';
}
static get lfsTransferAgent(): string {
return Input.getInput('lfsTransferAgent') ?? '';
}
static get lfsTransferAgentArgs(): string {
return Input.getInput('lfsTransferAgentArgs') ?? '';
}
static get lfsStoragePaths(): string {
return Input.getInput('lfsStoragePaths') ?? '';
}
static get gitHooksEnabled(): boolean {
return (Input.getInput('gitHooksEnabled') ?? 'false') === 'true';
}
static get gitHooksSkipList(): string {
return Input.getInput('gitHooksSkipList') ?? '';
}
static get gitHooksRunBeforeBuild(): string {
return Input.getInput('gitHooksRunBeforeBuild') ?? '';
}
static get providerExecutable(): string {
return Input.getInput('providerExecutable') ?? '';
}
// GCP Cloud Run (Experimental)
static get gcpProject(): string {
return Input.getInput('gcpProject') ?? '';
}
static get gcpRegion(): string {
return Input.getInput('gcpRegion') ?? '';
}
static get gcpStorageType(): string {
return Input.getInput('gcpStorageType') ?? 'gcs-fuse';
}
static get gcpBucket(): string {
return Input.getInput('gcpBucket') ?? '';
}
static get gcpFilestoreIp(): string {
return Input.getInput('gcpFilestoreIp') ?? '';
}
static get gcpFilestoreShare(): string {
return Input.getInput('gcpFilestoreShare') ?? '/share1';
}
static get gcpMachineType(): string {
return Input.getInput('gcpMachineType') ?? 'e2-standard-4';
}
static get gcpDiskSizeGb(): string {
return Input.getInput('gcpDiskSizeGb') ?? '100';
}
static get gcpServiceAccount(): string {
return Input.getInput('gcpServiceAccount') ?? '';
}
static get gcpVpcConnector(): string {
return Input.getInput('gcpVpcConnector') ?? '';
}
// Azure Container Instances (Experimental)
static get azureResourceGroup(): string {
return Input.getInput('azureResourceGroup') ?? '';
}
static get azureLocation(): string {
return Input.getInput('azureLocation') ?? '';
}
static get azureStorageType(): string {
return Input.getInput('azureStorageType') ?? 'azure-files';
}
static get azureStorageAccount(): string {
return Input.getInput('azureStorageAccount') ?? '';
}
static get azureBlobContainer(): string {
return Input.getInput('azureBlobContainer') ?? 'unity-builds';
}
static get azureFileShareName(): string {
return Input.getInput('azureFileShareName') ?? 'unity-builds';
}
static get azureSubscriptionId(): string {
return Input.getInput('azureSubscriptionId') ?? '';
}
static get azureCpu(): string {
return Input.getInput('azureCpu') ?? '4';
}
static get azureMemoryGb(): string {
return Input.getInput('azureMemoryGb') ?? '16';
}
static get azureDiskSizeGb(): string {
return Input.getInput('azureDiskSizeGb') ?? '100';
}
static get azureSubnetId(): string {
return Input.getInput('azureSubnetId') ?? '';
}
// ### ### ###
// Remote PowerShell provider
// ### ### ###
static get remotePowershellHost(): string {
return Input.getInput('remotePowershellHost') ?? '';
}
static get remotePowershellCredential(): string {
return Input.getInput('remotePowershellCredential') ?? '';
}
static get remotePowershellTransport(): string {
return Input.getInput('remotePowershellTransport') ?? 'wsman';
}
// ### ### ###
// GitHub Actions provider
// ### ### ###
static get githubActionsRepo(): string {
return Input.getInput('githubActionsRepo') ?? '';
}
static get githubActionsWorkflow(): string {
return Input.getInput('githubActionsWorkflow') ?? '';
}
static get githubActionsToken(): string {
return Input.getInput('githubActionsToken') ?? '';
}
static get githubActionsRef(): string {
return Input.getInput('githubActionsRef') ?? 'main';
}
// ### ### ###
// GitLab CI provider
// ### ### ###
static get gitlabProjectId(): string {
return Input.getInput('gitlabProjectId') ?? '';
}
static get gitlabTriggerToken(): string {
return Input.getInput('gitlabTriggerToken') ?? '';
}
static get gitlabApiUrl(): string {
return Input.getInput('gitlabApiUrl') ?? 'https://gitlab.com';
}
static get gitlabRef(): string {
return Input.getInput('gitlabRef') ?? 'main';
}
// ### ### ###
// Ansible provider
// ### ### ###
static get ansibleInventory(): string {
return Input.getInput('ansibleInventory') ?? '';
}
static get ansiblePlaybook(): string {
return Input.getInput('ansiblePlaybook') ?? '';
}
static get ansibleExtraVars(): string {
return Input.getInput('ansibleExtraVars') ?? '';
}
static get ansibleVaultPassword(): string {
return Input.getInput('ansibleVaultPassword') ?? '';
}
static get gitIntegrityCheck(): boolean {
const input = Input.getInput('gitIntegrityCheck') ?? 'false';
return input === 'true';
static get testResultPath(): string {
return Input.getInput('testResultPath') ?? './test-results';
}
static get hotRunnerEnabled(): boolean {
@@ -578,12 +354,6 @@ class Input {
return input === 'true';
}
static get gitAutoRecover(): boolean {
const input = Input.getInput('gitAutoRecover') ?? 'false';
return input === 'true';
}
static get hotRunnerTransport(): 'websocket' | 'grpc' | 'named-pipe' {
return (Input.getInput('hotRunnerTransport') ?? 'websocket') as 'websocket' | 'grpc' | 'named-pipe';
}
@@ -610,46 +380,6 @@ class Input {
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);
}
static get testSuitePath(): string {
return Input.getInput('testSuitePath') ?? '';
}
static get testSuiteEvent(): string {
return Input.getInput('testSuiteEvent') ?? '';
}
static get testTaxonomyPath(): string {
return Input.getInput('testTaxonomyPath') ?? '';
}
static get testResultFormat(): string {
return Input.getInput('testResultFormat') ?? 'junit';
}
static get testResultPath(): string {
return Input.getInput('testResultPath') ?? './test-results';
}
public static ToEnvVarFormat(input: string) {
if (input.toUpperCase() === input) {
return input;

View File

@@ -1,140 +0,0 @@
import { OrchestratorFolders } from './orchestrator-folders';
jest.mock('../orchestrator', () => ({
__esModule: true,
default: {
buildParameters: {
orchestratorRepoName: 'game-ci/unity-builder',
githubRepo: 'myorg/myrepo',
gitPrivateToken: 'ghp_test123',
gitAuthMode: 'header',
buildGuid: 'test-guid',
projectPath: '',
buildPath: 'Builds',
cacheKey: 'test-cache',
},
lockedWorkspace: '',
},
}));
jest.mock('./orchestrator-options', () => ({
__esModule: true,
default: {
useSharedBuilder: false,
},
}));
jest.mock('../services/core/orchestrator-system', () => ({
OrchestratorSystem: {
Run: jest.fn().mockResolvedValue(''),
},
}));
const mockOrchestrator = require('../orchestrator').default;
describe('OrchestratorFolders git auth', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('useHeaderAuth', () => {
it('should return true when gitAuthMode is header', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'header';
expect(OrchestratorFolders.useHeaderAuth).toBe(true);
});
it('should return true when gitAuthMode is undefined (default)', () => {
mockOrchestrator.buildParameters.gitAuthMode = undefined;
expect(OrchestratorFolders.useHeaderAuth).toBe(true);
});
it('should return false when gitAuthMode is url', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'url';
expect(OrchestratorFolders.useHeaderAuth).toBe(false);
});
});
describe('unityBuilderRepoUrl', () => {
it('should not include token in URL when using header auth', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'header';
const url = OrchestratorFolders.unityBuilderRepoUrl;
expect(url).toBe('https://github.com/game-ci/unity-builder.git');
expect(url).not.toContain('ghp_test123');
});
it('should include token in URL when using url auth (legacy)', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'url';
const url = OrchestratorFolders.unityBuilderRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/game-ci/unity-builder.git');
});
});
describe('targetBuildRepoUrl', () => {
it('should not include token in URL when using header auth', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'header';
const url = OrchestratorFolders.targetBuildRepoUrl;
expect(url).toBe('https://github.com/myorg/myrepo.git');
expect(url).not.toContain('ghp_test123');
});
it('should include token in URL when using url auth (legacy)', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'url';
const url = OrchestratorFolders.targetBuildRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/myorg/myrepo.git');
});
});
describe('gitAuthConfigScript', () => {
it('should emit http.extraHeader commands in header mode', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'header';
const script = OrchestratorFolders.gitAuthConfigScript;
expect(script).toContain('http.extraHeader');
expect(script).toContain('GIT_PRIVATE_TOKEN');
expect(script).toContain('Authorization: Basic');
});
it('should emit no-op comment in url mode', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'url';
const script = OrchestratorFolders.gitAuthConfigScript;
expect(script).toContain('legacy');
expect(script).not.toContain('http.extraHeader');
});
});
describe('configureGitAuth', () => {
it('should run git config with http.extraHeader in header mode', async () => {
mockOrchestrator.buildParameters.gitAuthMode = 'header';
mockOrchestrator.buildParameters.gitPrivateToken = 'ghp_test123';
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
await OrchestratorFolders.configureGitAuth();
// Verify the base64 encoding and extraHeader config are correct
const expectedEncoded = Buffer.from('x-access-token:ghp_test123').toString('base64');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining(expectedEncoded));
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('.extraHeader'));
});
it('should not run git config in url mode', async () => {
mockOrchestrator.buildParameters.gitAuthMode = 'url';
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
await OrchestratorFolders.configureGitAuth();
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
});
it('should not run git config when no token is available', async () => {
mockOrchestrator.buildParameters.gitAuthMode = 'header';
mockOrchestrator.buildParameters.gitPrivateToken = '';
const originalEnv = process.env.GIT_PRIVATE_TOKEN;
delete process.env.GIT_PRIVATE_TOKEN;
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
await OrchestratorFolders.configureGitAuth();
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
if (originalEnv !== undefined) process.env.GIT_PRIVATE_TOKEN = originalEnv;
});
});
});

View File

@@ -72,67 +72,14 @@ export class OrchestratorFolders {
return path.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `Library`);
}
/**
* Whether to use http.extraHeader for git authentication (secure, default)
* instead of embedding the token in clone URLs (legacy).
*/
public static get useHeaderAuth(): boolean {
return Orchestrator.buildParameters.gitAuthMode !== 'url';
}
public static get unityBuilderRepoUrl(): string {
if (OrchestratorFolders.useHeaderAuth) {
return `https://github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
}
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
}
public static get targetBuildRepoUrl(): string {
if (OrchestratorFolders.useHeaderAuth) {
return `https://github.com/${Orchestrator.buildParameters.githubRepo}.git`;
}
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.githubRepo}.git`;
}
/**
* Shell commands to configure git authentication via http.extraHeader.
* Uses GIT_PRIVATE_TOKEN env var so the token never appears in clone URLs or git config output.
* This is the same mechanism used by actions/checkout.
*
* Only emits commands when gitAuthMode is 'header' (default). In 'url' mode,
* returns a no-op comment since the token is already in the URL.
*/
public static get gitAuthConfigScript(): string {
if (!OrchestratorFolders.useHeaderAuth) {
return `# git auth: using token-in-URL mode (legacy)`;
}
return `# git auth: configuring http.extraHeader (secure mode)
if [ -n "$GIT_PRIVATE_TOKEN" ]; then
git config --global http.https://github.com/.extraHeader "Authorization: Basic $(printf '%s' "x-access-token:$GIT_PRIVATE_TOKEN" | base64 -w 0)"
fi`;
}
/**
* Configure git authentication via http.extraHeader in the current Node process.
* For use in the remote-client where shell scripts aren't used.
* Only configures when gitAuthMode is 'header' (default).
*/
public static async configureGitAuth(): Promise<void> {
if (!OrchestratorFolders.useHeaderAuth) return;
const token = Orchestrator.buildParameters.gitPrivateToken || process.env.GIT_PRIVATE_TOKEN || '';
if (!token) return;
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
const { OrchestratorSystem } = await import('../services/core/orchestrator-system');
await OrchestratorSystem.Run(
`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`,
);
}
public static get buildVolumeFolder() {
return 'data';
}

View File

@@ -1,53 +0,0 @@
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

@@ -138,36 +138,6 @@ class OrchestratorOptions {
return provider || 'local';
}
static get fallbackProviderStrategy(): string {
return OrchestratorOptions.getInput('fallbackProviderStrategy') || '';
}
static get runnerCheckEnabled(): boolean {
return OrchestratorOptions.getInput('runnerCheckEnabled') === 'true';
}
static get runnerCheckLabels(): string[] {
const labels = OrchestratorOptions.getInput('runnerCheckLabels');
return labels ? labels.split(',').map((l) => l.trim()) : [];
}
static get runnerCheckMinAvailable(): number {
return Number(OrchestratorOptions.getInput('runnerCheckMinAvailable')) || 1;
}
static get retryOnFallback(): boolean {
return OrchestratorOptions.getInput('retryOnFallback') === 'true';
}
static get providerInitTimeout(): number {
return Number(OrchestratorOptions.getInput('providerInitTimeout')) || 0;
}
static get gitAuthMode(): string {
return OrchestratorOptions.getInput('gitAuthMode') || 'header';
}
static get containerCpu(): string {
return OrchestratorOptions.getInput('containerCpu') || `1024`;
}
@@ -220,10 +190,6 @@ class OrchestratorOptions {
return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || [];
}
static get secretSource(): string {
return OrchestratorOptions.getInput('secretSource') || '';
}
static get inputPullCommand(): string {
const value = OrchestratorOptions.getInput('inputPullCommand');

View File

@@ -1,9 +1,6 @@
import * as core from '@actions/core';
import Input from '../../input';
import { GenericInputReader } from '../../input-readers/generic-input-reader';
import OrchestratorOptions from './orchestrator-options';
import { SecretSourceService, validateSecretKey } from '../services/secrets/secret-source-service';
import OrchestratorLogger from '../services/core/orchestrator-logger';
const formatFunction = (value: string, arguments_: any[]) => {
for (const element of arguments_) {
@@ -16,6 +13,8 @@ const formatFunction = (value: string, arguments_: any[]) => {
class OrchestratorQueryOverride {
static queryOverrides: { [key: string]: string } | undefined;
// TODO accept premade secret sources or custom secret source definition yamls
public static query(key: string, alternativeKey: string) {
if (OrchestratorQueryOverride.queryOverrides && OrchestratorQueryOverride.queryOverrides[key] !== undefined) {
return OrchestratorQueryOverride.queryOverrides[key];
@@ -50,62 +49,14 @@ class OrchestratorQueryOverride {
throw new Error(`Should not be trying to run override query on ${query}`);
}
// Validate the query key before interpolating it into a shell command
validateSecretKey(query);
const result = await GenericInputReader.Run(
return await GenericInputReader.Run(
formatFunction(OrchestratorOptions.inputPullCommand, [{ key: 0, value: query }]),
);
// Mask the fetched secret value so it does not appear in GitHub Actions logs
if (result && result.trim().length > 0) {
core.setSecret(result);
}
return result;
}
/**
* Populate query overrides using either:
* 1. Premade/custom secret sources (via secretSource input), or
* 2. Shell command (via inputPullCommand, legacy approach)
*
* The secretSource input takes precedence if set. It supports:
* - Premade names: 'aws-secrets-manager', 'aws-parameter-store', 'gcp-secret-manager', 'azure-key-vault', 'env'
* - Custom commands: any string containing {0} placeholder
* - YAML file path: a path ending in .yml or .yaml containing custom source definitions
*/
public static async PopulateQueryOverrideInput() {
const queries = OrchestratorOptions.pullInputList;
OrchestratorQueryOverride.queryOverrides = {};
const secretSource = OrchestratorOptions.secretSource;
// Use SecretSourceService if secretSource is configured
if (secretSource) {
OrchestratorLogger.log(`Using secret source: ${secretSource}`);
// YAML file: load definitions and use the first source
if (secretSource.endsWith('.yml') || secretSource.endsWith('.yaml')) {
const definitions = SecretSourceService.loadFromYaml(secretSource);
if (definitions.length > 0) {
OrchestratorLogger.log(`Loaded ${definitions.length} secret source(s) from ${secretSource}`);
for (const key of queries) {
OrchestratorQueryOverride.queryOverrides[key] = await SecretSourceService.fetchSecret(definitions[0], key);
}
}
return;
}
// Premade or custom command source
const results = await SecretSourceService.fetchAll(secretSource, queries);
Object.assign(OrchestratorQueryOverride.queryOverrides, results);
return;
}
// Legacy: use inputPullCommand if set
for (const element of queries) {
if (OrchestratorQueryOverride.shouldUseOverride(element)) {
OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element);

View File

@@ -13,12 +13,6 @@ import OrchestratorEnvironmentVariable from './options/orchestrator-environment-
import TestOrchestrator from './providers/test';
import LocalOrchestrator from './providers/local';
import LocalDockerOrchestrator from './providers/docker';
import GcpCloudRunProvider from './providers/gcp-cloud-run';
import AzureAciProvider from './providers/azure-aci';
import RemotePowershellProvider from './providers/remote-powershell';
import GitHubActionsProvider from './providers/github-actions';
import GitLabCIProvider from './providers/gitlab-ci';
import AnsibleProvider from './providers/ansible';
import loadProvider from './providers/provider-loader';
import GitHub from '../github';
import SharedWorkspaceLocking from './services/core/shared-workspace-locking';
@@ -26,7 +20,6 @@ import { FollowLogStreamService } from './services/core/follow-log-stream-servic
import OrchestratorResult from './services/core/orchestrator-result';
import OrchestratorOptions from './options/orchestrator-options';
import ResourceTracking from './services/core/resource-tracking';
import { RunnerAvailabilityService } from './services/core/runner-availability-service';
class Orchestrator {
public static Provider: ProviderInterface;
@@ -83,42 +76,6 @@ class Orchestrator {
private static async setupSelectedBuildPlatform() {
OrchestratorLogger.log(`Orchestrator platform selected ${Orchestrator.buildParameters.providerStrategy}`);
// Check runner availability and apply fallback if needed
if (Orchestrator.buildParameters.runnerCheckEnabled && Orchestrator.buildParameters.fallbackProviderStrategy) {
const owner = OrchestratorOptions.githubOwner;
const repo = OrchestratorOptions.githubRepoName;
const token = Orchestrator.buildParameters.gitPrivateToken || process.env.GITHUB_TOKEN || '';
OrchestratorLogger.log(
`Checking runner availability (labels: [${Orchestrator.buildParameters.runnerCheckLabels.join(', ')}], min: ${
Orchestrator.buildParameters.runnerCheckMinAvailable
})`,
);
const result = await RunnerAvailabilityService.checkAvailability(
owner,
repo,
token,
Orchestrator.buildParameters.runnerCheckLabels,
Orchestrator.buildParameters.runnerCheckMinAvailable,
);
OrchestratorLogger.log(
`Runner check: ${result.totalRunners} total, ${result.matchingRunners} matching, ${result.idleRunners} idle — ${result.reason}`,
);
if (result.shouldFallback) {
const original = Orchestrator.buildParameters.providerStrategy;
const fallback = Orchestrator.buildParameters.fallbackProviderStrategy;
OrchestratorLogger.log(`Falling back from '${original}' to '${fallback}' — ${result.reason}`);
Orchestrator.buildParameters.providerStrategy = fallback;
core.setOutput('providerFallbackUsed', 'true');
core.setOutput('providerFallbackReason', result.reason);
} else {
core.setOutput('providerFallbackUsed', 'false');
}
}
// Detect LocalStack endpoints and handle AWS provider appropriately
// AWS_FORCE_PROVIDER options:
// - 'aws': Force AWS provider (requires LocalStack Pro with ECS support)
@@ -172,17 +129,6 @@ class Orchestrator {
// Store whether we should validate AWS templates (used by aws-local mode)
Orchestrator.validateAwsTemplates = validateAwsTemplates;
// Check for CLI provider executable
if (Orchestrator.buildParameters.providerExecutable) {
const { default: CliProvider } = await import('./providers/cli');
Orchestrator.Provider = new CliProvider(
Orchestrator.buildParameters.providerExecutable,
Orchestrator.buildParameters,
);
OrchestratorLogger.log(`Using CLI provider executable: ${Orchestrator.buildParameters.providerExecutable}`);
return;
}
switch (provider) {
case 'k8s':
Orchestrator.Provider = new Kubernetes(Orchestrator.buildParameters);
@@ -212,25 +158,6 @@ class Orchestrator {
case 'local':
Orchestrator.Provider = new LocalOrchestrator();
break;
case 'gcp-cloud-run':
OrchestratorLogger.log('⚠ EXPERIMENTAL: GCP Cloud Run Jobs provider');
Orchestrator.Provider = new GcpCloudRunProvider(Orchestrator.buildParameters);
break;
case 'azure-aci':
OrchestratorLogger.log('⚠ EXPERIMENTAL: Azure Container Instances provider');
Orchestrator.Provider = new AzureAciProvider(Orchestrator.buildParameters);
case 'remote-powershell':
Orchestrator.Provider = new RemotePowershellProvider(Orchestrator.buildParameters);
break;
case 'github-actions':
Orchestrator.Provider = new GitHubActionsProvider(Orchestrator.buildParameters);
break;
case 'gitlab-ci':
Orchestrator.Provider = new GitLabCIProvider(Orchestrator.buildParameters);
break;
case 'ansible':
Orchestrator.Provider = new AnsibleProvider(Orchestrator.buildParameters);
break;
default:
// Try to load provider using the dynamic loader for unknown providers
try {
@@ -255,30 +182,6 @@ class Orchestrator {
if (baseImage.includes(`undefined`)) {
throw new Error(`baseImage is undefined`);
}
try {
return await Orchestrator.runWithProvider(buildParameters, baseImage);
} catch (primaryError: any) {
// Retry on fallback provider if enabled and a fallback is configured
const fallback = buildParameters.fallbackProviderStrategy;
const alreadyOnFallback = buildParameters.providerStrategy === fallback;
if (buildParameters.retryOnFallback && fallback && !alreadyOnFallback) {
OrchestratorLogger.log(
`Primary provider '${buildParameters.providerStrategy}' failed: ${primaryError.message}`,
);
OrchestratorLogger.log(`Retrying build on fallback provider '${fallback}'...`);
buildParameters.providerStrategy = fallback;
core.setOutput('providerFallbackUsed', 'true');
core.setOutput('providerFallbackReason', `Primary provider failed: ${primaryError.message}`);
return await Orchestrator.runWithProvider(buildParameters, baseImage);
}
throw primaryError;
}
}
private static async runWithProvider(buildParameters: BuildParameters, baseImage: string) {
await Orchestrator.setup(buildParameters);
// When aws-local mode is enabled, validate AWS CloudFormation templates
@@ -286,10 +189,12 @@ class Orchestrator {
if (Orchestrator.validateAwsTemplates) {
await Orchestrator.validateAwsCloudFormationTemplates();
}
// Setup workflow with optional init timeout
await Orchestrator.setupWorkflowWithTimeout();
await Orchestrator.Provider.setupWorkflow(
Orchestrator.buildParameters.buildGuid,
Orchestrator.buildParameters,
Orchestrator.buildParameters.branch,
Orchestrator.defaultSecrets,
);
try {
if (buildParameters.maxRetainedWorkspaces > 0) {
Orchestrator.lockedWorkspace = SharedWorkspaceLocking.NewWorkspaceName();
@@ -370,39 +275,6 @@ class Orchestrator {
}
}
/**
* Runs setupWorkflow with an optional timeout. If providerInitTimeout is set and the
* provider takes longer than that to initialize, throws an error that triggers
* retry-on-fallback (if enabled).
*/
private static async setupWorkflowWithTimeout() {
const timeoutSeconds = Orchestrator.buildParameters.providerInitTimeout;
const setupPromise = Orchestrator.Provider.setupWorkflow(
Orchestrator.buildParameters.buildGuid,
Orchestrator.buildParameters,
Orchestrator.buildParameters.branch,
Orchestrator.defaultSecrets,
);
if (timeoutSeconds <= 0) {
await setupPromise;
return;
}
OrchestratorLogger.log(`Provider init timeout: ${timeoutSeconds}s`);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error(`Provider initialization timed out after ${timeoutSeconds}s`)),
timeoutSeconds * 1000,
);
});
await Promise.race([setupPromise, timeoutPromise]);
}
private static async updateStatusWithBuildParameters() {
const content = { ...Orchestrator.buildParameters };
content.gitPrivateToken = ``;

View File

@@ -1,291 +0,0 @@
import AnsibleProvider from '.';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import * as core from '@actions/core';
jest.mock('../../services/core/orchestrator-system');
jest.mock('../../services/core/orchestrator-logger');
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(() => ''),
}));
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction<typeof OrchestratorLogger.logWarning>;
function createBuildParameters(overrides: Partial<BuildParameters> = {}): BuildParameters {
return {
ansibleInventory: '/etc/ansible/hosts',
ansiblePlaybook: '/playbooks/unity-build.yml',
ansibleExtraVars: '',
ansibleVaultPassword: '',
...overrides,
} as BuildParameters;
}
describe('AnsibleProvider', () => {
let provider: AnsibleProvider;
beforeEach(() => {
jest.clearAllMocks();
provider = new AnsibleProvider(createBuildParameters());
});
describe('constructor', () => {
it('initializes with all provided parameters', () => {
const params = createBuildParameters({
ansibleInventory: '/custom/inventory',
ansiblePlaybook: '/custom/playbook.yml',
ansibleExtraVars: '{"key":"value"}',
ansibleVaultPassword: '/vault/pass',
});
const p = new AnsibleProvider(params);
expect(p).toBeDefined();
});
it('handles missing optional parameters gracefully', () => {
const params = createBuildParameters({
ansiblePlaybook: undefined,
ansibleExtraVars: undefined,
ansibleVaultPassword: undefined,
});
const p = new AnsibleProvider(params);
expect(p).toBeDefined();
});
});
describe('setupWorkflow', () => {
it('verifies ansible binary, ansible-playbook binary, and inventory exist', async () => {
mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible --version
mockRun.mockResolvedValueOnce('/usr/bin/ansible-playbook'); // ansible-playbook check
mockRun.mockResolvedValueOnce(''); // test -e inventory
await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []);
expect(mockRun).toHaveBeenCalledTimes(3);
expect(mockRun.mock.calls[0][0]).toContain('ansible --version');
expect(mockRun.mock.calls[1][0]).toContain('ansible-playbook');
expect(mockRun.mock.calls[2][0]).toContain('test -e "/etc/ansible/hosts"');
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ansible'));
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ansible-playbook binary verified'));
});
it('throws when inventory is not configured', async () => {
const params = createBuildParameters({ ansibleInventory: '' });
provider = new AnsibleProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'ansibleInventory is required',
);
});
it('throws when ansible binary is not found on PATH', async () => {
mockRun.mockRejectedValueOnce(new Error('command not found: ansible'));
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
'Ansible not found on PATH',
);
});
it('throws when ansible-playbook binary is not found', async () => {
mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible version OK
mockRun.mockRejectedValueOnce(new Error('command not found')); // ansible-playbook missing
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
'ansible-playbook not found on PATH',
);
expect(core.error).toHaveBeenCalledWith('ansible-playbook not found. Install Ansible or ensure it is in PATH.');
});
it('throws when inventory file does not exist', async () => {
mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible version OK
mockRun.mockResolvedValueOnce('/usr/bin/ansible-playbook'); // ansible-playbook OK
mockRun.mockRejectedValueOnce(new Error('test -e failed')); // inventory missing
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
'Inventory not found: /etc/ansible/hosts',
);
});
});
describe('runTaskInWorkflow', () => {
it('constructs ansible-playbook command with correct variables and returns output', async () => {
mockRun.mockResolvedValueOnce('PLAY [build] *****\nok: [server1]\nPLAY RECAP');
const result = await provider.runTaskInWorkflow(
'guid-run1',
'unityci/editor:2021.3',
'echo build',
'/mount',
'/workspace',
[],
[],
);
expect(result).toContain('PLAY [build]');
const command = mockRun.mock.calls[0][0];
expect(command).toContain('ansible-playbook');
expect(command).toContain('-i "/etc/ansible/hosts"');
expect(command).toContain('"/playbooks/unity-build.yml"');
expect(command).toContain('--no-color');
expect(command).toContain('build_guid');
expect(command).toContain('guid-run1');
expect(command).toContain('build_image');
expect(command).toContain('unityci/editor:2021.3');
expect(command).toContain('build_commands');
expect(command).toContain('mount_dir');
expect(command).toContain('working_dir');
});
it('throws when playbook is not configured', async () => {
const params = createBuildParameters({ ansiblePlaybook: '' });
provider = new AnsibleProvider(params);
await expect(provider.runTaskInWorkflow('guid-nopb', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'ansiblePlaybook is required',
);
});
it('passes environment variables as extra-vars in snake_case', async () => {
mockRun.mockResolvedValueOnce('ok');
const env = [
{ name: 'UNITY_LICENSE', value: 'lic-data' },
{ name: 'BUILD_TARGET', value: 'Linux64' },
];
await provider.runTaskInWorkflow('guid-env', 'img', 'cmd', '/m', '/w', env as any, []);
const command = mockRun.mock.calls[0][0];
// Environment variable names are lowercased as Ansible variables
expect(command).toContain('unity_license');
expect(command).toContain('lic-data');
expect(command).toContain('build_target');
expect(command).toContain('Linux64');
});
it('merges user-provided extra vars from JSON string', async () => {
const params = createBuildParameters({
ansibleExtraVars: JSON.stringify({ custom_var: 'custom_value', another: '42' }),
});
provider = new AnsibleProvider(params);
mockRun.mockResolvedValueOnce('ok');
await provider.runTaskInWorkflow('guid-extra', 'img', 'cmd', '/m', '/w', [], []);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('custom_var');
expect(command).toContain('custom_value');
expect(command).toContain('another');
});
it('logs warning when extra vars JSON is invalid but continues', async () => {
const params = createBuildParameters({ ansibleExtraVars: 'not-valid-json{{{' });
provider = new AnsibleProvider(params);
mockRun.mockResolvedValueOnce('ok');
await provider.runTaskInWorkflow('guid-badjson', 'img', 'cmd', '/m', '/w', [], []);
expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Failed to parse ansibleExtraVars'));
});
it('includes vault password file flag when configured', async () => {
const params = createBuildParameters({ ansibleVaultPassword: '/secure/vault-pass.txt' });
provider = new AnsibleProvider(params);
mockRun.mockResolvedValueOnce('ok');
await provider.runTaskInWorkflow('guid-vault', 'img', 'cmd', '/m', '/w', [], []);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('--vault-password-file "/secure/vault-pass.txt"');
});
it('does not include vault password flag when not configured', async () => {
mockRun.mockResolvedValueOnce('ok');
await provider.runTaskInWorkflow('guid-novault', 'img', 'cmd', '/m', '/w', [], []);
const command = mockRun.mock.calls[0][0];
expect(command).not.toContain('--vault-password-file');
});
it('prefixes secrets as environment variables in the command', async () => {
mockRun.mockResolvedValueOnce('ok');
const secrets = [
{ ParameterKey: 'key1', EnvironmentVariable: 'SECRET_TOKEN', ParameterValue: 'tok-abc' },
{ ParameterKey: 'key2', EnvironmentVariable: 'DEPLOY_KEY', ParameterValue: 'dk-xyz' },
];
await provider.runTaskInWorkflow('guid-secrets', 'img', 'cmd', '/m', '/w', [], secrets as any);
const command = mockRun.mock.calls[0][0];
expect(command).toMatch(/^SECRET_TOKEN='tok-abc'/);
expect(command).toContain("DEPLOY_KEY='dk-xyz'");
expect(command).toContain('ansible-playbook');
});
it('throws and logs warning when playbook execution fails', async () => {
const execError = new Error('UNREACHABLE! Host unreachable');
mockRun.mockRejectedValueOnce(execError);
await expect(provider.runTaskInWorkflow('guid-hostfail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'UNREACHABLE',
);
expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Playbook failed'));
});
});
describe('cleanupWorkflow', () => {
it('completes without error and logs cleanup message', async () => {
await provider.cleanupWorkflow(createBuildParameters(), 'main', []);
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleanup complete'));
});
});
describe('garbageCollect', () => {
it('returns empty string (no-op)', async () => {
const result = await provider.garbageCollect('', false, 0, false, false);
expect(result).toBe('');
});
});
describe('listResources', () => {
it('returns inventory path as a resource when configured', async () => {
const resources = await provider.listResources();
expect(resources).toHaveLength(1);
expect(resources[0].Name).toBe('/etc/ansible/hosts');
});
it('returns empty array when inventory is not configured', async () => {
const params = createBuildParameters({ ansibleInventory: '' });
provider = new AnsibleProvider(params);
const resources = await provider.listResources();
expect(resources).toEqual([]);
});
});
describe('listWorkflow', () => {
it('returns empty array (not implemented)', async () => {
const workflows = await provider.listWorkflow();
expect(workflows).toEqual([]);
});
});
describe('watchWorkflow', () => {
it('returns empty string (not implemented)', async () => {
const result = await provider.watchWorkflow();
expect(result).toBe('');
});
});
});

View File

@@ -1,4 +1,3 @@
import * as core from '@actions/core';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
@@ -54,15 +53,6 @@ class AnsibleProvider implements ProviderInterface {
throw new Error(`Ansible not found on PATH: ${error.message || error}`);
}
// Verify ansible-playbook binary exists (may be separate from ansible)
try {
await OrchestratorSystem.Run('command -v ansible-playbook || which ansible-playbook || where ansible-playbook');
OrchestratorLogger.log(`[Ansible] ansible-playbook binary verified`);
} catch (error: any) {
core.error('ansible-playbook not found. Install Ansible or ensure it is in PATH.');
throw new Error(`ansible-playbook not found on PATH: ${error.message || error}`);
}
// Verify inventory exists
try {
await OrchestratorSystem.Run(`test -e "${this.inventory}"`);

View File

@@ -1,536 +0,0 @@
/**
* Azure Container Instances (ACI) Provider (Experimental)
*
* Executes Unity builds as Azure Container Instances with configurable storage backends.
*
* Storage types:
* - azure-files: SMB file share mount via Azure Files. Up to 100 TiB per share,
* premium throughput. Default.
* Requires: azureStorageAccount, azureFileShareName
* - blob-copy: Copy artifacts in/out of Azure Blob Storage before/after the build.
* No mount overhead, simpler.
* Requires: azureStorageAccount, azureBlobContainer
* - azure-files-nfs: NFS 4.1 file share mount. True POSIX semantics, no SMB lock overhead,
* better for Unity Library caching (many small random reads).
* Requires: azureStorageAccount, azureFileShareName, Premium FileStorage,
* VNet integration (azureSubnetId)
* - in-memory: emptyDir volume (tmpfs). Fastest I/O but volatile, size limited by
* container memory allocation.
*
* Prerequisites:
* - Azure CLI authenticated (az login or service principal)
* - A resource group for build resources
* - Contributor role on the resource group
*
* @experimental This provider is experimental. APIs and behavior may change.
*/
import { ProviderInterface } from '../provider-interface';
import BuildParameters from '../../../build-parameters';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import { Input } from '../../..';
import ResourceTracking from '../../services/core/resource-tracking';
type AzureStorageType = 'azure-files' | 'blob-copy' | 'azure-files-nfs' | 'in-memory';
class AzureAciProvider implements ProviderInterface {
private readonly resourceGroup: string;
private readonly location: string;
private readonly storageType: AzureStorageType;
private readonly storageAccount: string;
private readonly blobContainer: string;
private readonly fileShareName: string;
private readonly subscriptionId: string;
private readonly cpu: number;
private readonly memoryGb: number;
private readonly diskSizeGb: number;
private readonly subnetId: string;
private buildParameters: BuildParameters;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.resourceGroup = buildParameters.azureResourceGroup || process.env.AZURE_RESOURCE_GROUP || '';
this.location = buildParameters.azureLocation || Input.region || 'eastus';
this.storageType = (buildParameters.azureStorageType || 'azure-files') as AzureStorageType;
this.storageAccount = buildParameters.azureStorageAccount || process.env.AZURE_STORAGE_ACCOUNT || '';
this.blobContainer = buildParameters.azureBlobContainer || 'unity-builds';
this.fileShareName = buildParameters.azureFileShareName || 'unity-builds';
this.subscriptionId = buildParameters.azureSubscriptionId || process.env.AZURE_SUBSCRIPTION_ID || '';
this.cpu = Number.parseInt(buildParameters.azureCpu || '4', 10);
this.memoryGb = Number.parseInt(buildParameters.azureMemoryGb || '16', 10);
this.diskSizeGb = Number.parseInt(buildParameters.azureDiskSizeGb || '100', 10);
this.subnetId = buildParameters.azureSubnetId || '';
OrchestratorLogger.log('[Azure ACI] Provider initialized (EXPERIMENTAL)');
OrchestratorLogger.log(`[Azure ACI] Resource Group: ${this.resourceGroup || '(not set)'}`);
OrchestratorLogger.log(`[Azure ACI] Location: ${this.location}`);
OrchestratorLogger.log(`[Azure ACI] Storage: ${this.storageType}`);
OrchestratorLogger.log(`[Azure ACI] Resources: ${this.cpu} CPU, ${this.memoryGb}GB RAM`);
this.validateStorageConfig();
}
private validateStorageConfig(): void {
switch (this.storageType) {
case 'azure-files':
if (!this.storageAccount) {
OrchestratorLogger.logWarning(
'[Azure ACI] Storage type "azure-files" requires azureStorageAccount to be set.',
);
} else {
OrchestratorLogger.log(`[Azure ACI] File Share: ${this.storageAccount}/${this.fileShareName} (SMB)`);
}
break;
case 'azure-files-nfs':
if (!this.storageAccount) {
OrchestratorLogger.logWarning(
'[Azure ACI] Storage type "azure-files-nfs" requires azureStorageAccount (Premium FileStorage).',
);
}
if (!this.subnetId) {
OrchestratorLogger.logWarning('[Azure ACI] NFS file shares require VNet integration. Set azureSubnetId.');
} else {
OrchestratorLogger.log(`[Azure ACI] File Share: ${this.storageAccount}/${this.fileShareName} (NFS 4.1)`);
}
break;
case 'blob-copy':
if (!this.storageAccount) {
OrchestratorLogger.logWarning('[Azure ACI] Storage type "blob-copy" requires azureStorageAccount to be set.');
} else {
OrchestratorLogger.log(`[Azure ACI] Blob container: ${this.storageAccount}/${this.blobContainer}`);
}
break;
case 'in-memory':
OrchestratorLogger.log(
`[Azure ACI] In-memory volume (emptyDir): limited by ${this.memoryGb}GB container memory`,
);
break;
default:
OrchestratorLogger.logWarning(
`[Azure ACI] Unknown storage type '${this.storageType}'. Valid: azure-files, blob-copy, azure-files-nfs, in-memory`,
);
}
if (!this.resourceGroup) {
OrchestratorLogger.logWarning(
'[Azure ACI] No resource group specified. Set azureResourceGroup input or AZURE_RESOURCE_GROUP env var.',
);
}
}
async setupWorkflow(
buildGuid: string,
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
OrchestratorLogger.log(`[Azure ACI] Setting up workflow for build ${buildGuid}`);
ResourceTracking.logAllocationSummary('azure-aci setup');
// Verify Azure CLI is available
try {
await OrchestratorSystem.Run('az version --output json', false, true);
OrchestratorLogger.log('[Azure ACI] Azure CLI detected');
} catch {
throw new Error(
'[Azure ACI] Azure CLI not found. Install Azure CLI: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli',
);
}
if (this.subscriptionId) {
await OrchestratorSystem.Run(`az account set --subscription="${this.subscriptionId}"`);
}
// Ensure resource group exists
if (this.resourceGroup) {
try {
await OrchestratorSystem.Run(`az group show --name "${this.resourceGroup}" --output json`, false, true);
OrchestratorLogger.log(`[Azure ACI] Resource group ${this.resourceGroup} exists`);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating resource group ${this.resourceGroup}`);
await OrchestratorSystem.Run(`az group create --name "${this.resourceGroup}" --location "${this.location}"`);
}
}
// Storage-specific setup
switch (this.storageType) {
case 'azure-files':
await this.setupStorageAccount('Standard_LRS', 'StorageV2');
await this.setupFileShare();
break;
case 'azure-files-nfs':
await this.setupStorageAccount('Premium_LRS', 'FileStorage');
await this.setupNfsFileShare();
break;
case 'blob-copy':
await this.setupStorageAccount('Standard_LRS', 'StorageV2');
await this.setupBlobContainer();
break;
case 'in-memory':
// No storage setup needed
break;
}
}
private async setupStorageAccount(sku: string, kind: string): Promise<void> {
if (!this.storageAccount || !this.resourceGroup) return;
try {
await OrchestratorSystem.Run(
`az storage account show --name "${this.storageAccount}" --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
OrchestratorLogger.log(`[Azure ACI] Storage account ${this.storageAccount} exists`);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating storage account ${this.storageAccount} (${sku}, ${kind})`);
await OrchestratorSystem.Run(
`az storage account create --name "${this.storageAccount}" --resource-group "${this.resourceGroup}" --location "${this.location}" --sku ${sku} --kind ${kind}`,
);
}
}
private async setupFileShare(): Promise<void> {
if (!this.storageAccount || !this.resourceGroup) return;
try {
await OrchestratorSystem.Run(
`az storage share-rm show --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating file share ${this.fileShareName} (${this.diskSizeGb}GB)`);
await OrchestratorSystem.Run(
`az storage share-rm create --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --quota ${this.diskSizeGb}`,
);
}
}
private async setupNfsFileShare(): Promise<void> {
if (!this.storageAccount || !this.resourceGroup) return;
try {
await OrchestratorSystem.Run(
`az storage share-rm show --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating NFS file share ${this.fileShareName} (${this.diskSizeGb}GB)`);
await OrchestratorSystem.Run(
`az storage share-rm create --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --quota ${this.diskSizeGb} --enabled-protocols NFS`,
);
}
}
private async setupBlobContainer(): Promise<void> {
if (!this.storageAccount || !this.resourceGroup) return;
try {
await OrchestratorSystem.Run(
`az storage container show --name "${this.blobContainer}" --account-name "${this.storageAccount}" --output json`,
false,
true,
);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating blob container ${this.blobContainer}`);
await OrchestratorSystem.Run(
`az storage container create --name "${this.blobContainer}" --account-name "${this.storageAccount}"`,
);
}
}
private async getStorageKey(): Promise<string> {
if (!this.storageAccount || !this.resourceGroup) return '';
try {
const keyJson = await OrchestratorSystem.Run(
`az storage account keys list --account-name "${this.storageAccount}" --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
const keys = JSON.parse(keyJson);
return keys[0]?.value || '';
} catch (error: any) {
OrchestratorLogger.logWarning(`[Azure ACI] Could not get storage key: ${error.message}`);
return '';
}
}
private async buildVolumeFlags(mountdir: string): Promise<string> {
switch (this.storageType) {
case 'azure-files': {
const storageKey = await this.getStorageKey();
if (!storageKey) return '';
return [
`--azure-file-volume-account-name "${this.storageAccount}"`,
`--azure-file-volume-account-key "${storageKey}"`,
`--azure-file-volume-share-name "${this.fileShareName}"`,
`--azure-file-volume-mount-path "${mountdir}"`,
].join(' ');
}
case 'azure-files-nfs': {
// ACI NFS mount uses a YAML deployment template; for CLI we use the same
// azure-file-volume flags but the share must be NFS-enabled and
// the container must be in a VNet
const storageKey = await this.getStorageKey();
if (!storageKey) return '';
return [
`--azure-file-volume-account-name "${this.storageAccount}"`,
`--azure-file-volume-account-key "${storageKey}"`,
`--azure-file-volume-share-name "${this.fileShareName}"`,
`--azure-file-volume-mount-path "${mountdir}"`,
].join(' ');
}
case 'in-memory':
// ACI emptyDir volumes require YAML deployment; for simplicity we skip
// the volume mount and let the container use its own filesystem
OrchestratorLogger.log('[Azure ACI] In-memory mode: using container filesystem (no persistent mount)');
return '';
case 'blob-copy':
// No volume mount — artifacts are copied in/out via az storage blob commands
return '';
default:
return '';
}
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[Azure ACI] Running task for build ${buildGuid}`);
ResourceTracking.logAllocationSummary('azure-aci task');
const containerName = `unity-build-${buildGuid}`
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.slice(0, 63);
// Build environment variable flags
const allEnvVars = [
...environment.map((env) => `${env.name}=${env.value}`),
...secrets.map((s) => `${s.EnvironmentVariable}=${s.ParameterValue}`),
];
const envFlag = allEnvVars.length > 0 ? `--environment-variables ${allEnvVars.map((e) => `"${e}"`).join(' ')}` : '';
// Build volume flags based on storage type
const volumeFlags = await this.buildVolumeFlags(mountdir);
const subnetFlag = this.subnetId ? `--subnet "${this.subnetId}"` : '';
// For blob-copy, wrap the user command with copy-in/copy-out steps
let effectiveCommands = commands;
if (this.storageType === 'blob-copy' && this.storageAccount && commands) {
effectiveCommands = [
`az storage blob download-batch --destination "${mountdir}" --source "${this.blobContainer}" --account-name "${this.storageAccount}" 2>/dev/null || true`,
commands,
`az storage blob upload-batch --source "${mountdir}" --destination "${this.blobContainer}" --account-name "${this.storageAccount}" --overwrite`,
].join(' && ');
}
const commandFlag = effectiveCommands
? `--command-line "/bin/sh -c '${effectiveCommands.replace(/'/g, "'\\''")}'"`
: '';
const createCmd = [
'az container create',
`--resource-group "${this.resourceGroup}"`,
`--name "${containerName}"`,
`--image "${image}"`,
`--location "${this.location}"`,
`--cpu ${this.cpu}`,
`--memory ${this.memoryGb}`,
'--restart-policy Never',
'--os-type Linux',
volumeFlags,
envFlag,
subnetFlag,
commandFlag,
'--output json',
]
.filter(Boolean)
.join(' ');
try {
await OrchestratorSystem.Run(createCmd);
OrchestratorLogger.log(
`[Azure ACI] Container ${containerName} created (storage: ${this.storageType}), waiting for completion...`,
);
} catch (error: any) {
throw new Error(`[Azure ACI] Failed to create container: ${error.message}`);
}
const output = await this.waitForContainerCompletion(containerName);
return output;
}
private async waitForContainerCompletion(containerName: string): Promise<string> {
const maxWaitMs = 24 * 60 * 60 * 1000;
const pollIntervalMs = 15_000;
const startTime = Date.now();
let lastLogLength = 0;
while (Date.now() - startTime < maxWaitMs) {
try {
const stateJson = await OrchestratorSystem.Run(
`az container show --resource-group "${this.resourceGroup}" --name "${containerName}" --output json`,
false,
true,
);
const state = JSON.parse(stateJson);
const containerState =
state.containers?.[0]?.instanceView?.currentState?.state || state.instanceView?.state || 'Unknown';
const provisioningState = state.provisioningState || 'Unknown';
// Stream logs incrementally
try {
const logs = await OrchestratorSystem.Run(
`az container logs --resource-group "${this.resourceGroup}" --name "${containerName}"`,
false,
true,
);
if (logs && logs.length > lastLogLength) {
const newLogs = logs.slice(lastLogLength);
for (const line of newLogs.split('\n')) {
if (line.trim()) {
OrchestratorLogger.log(`[Build] ${line}`);
}
}
lastLogLength = logs.length;
}
} catch {
// Logs may not be available yet
}
if (containerState === 'Terminated' || provisioningState === 'Succeeded') {
const exitCode = state.containers?.[0]?.instanceView?.currentState?.exitCode;
if (exitCode !== undefined && exitCode !== 0) {
throw new Error(`[Azure ACI] Container exited with code ${exitCode}`);
}
OrchestratorLogger.log('[Azure ACI] Container completed successfully');
try {
return await OrchestratorSystem.Run(
`az container logs --resource-group "${this.resourceGroup}" --name "${containerName}"`,
false,
true,
);
} catch {
return '';
}
}
if (provisioningState === 'Failed') {
const detail =
state.containers?.[0]?.instanceView?.currentState?.detailStatus ||
state.containers?.[0]?.instanceView?.events?.map((e: any) => e.message).join('; ') ||
'Unknown error';
throw new Error(`[Azure ACI] Container provisioning failed: ${detail}`);
}
} catch (error: any) {
if (error.message?.includes('Container provisioning failed') || error.message?.includes('exited with code')) {
throw error;
}
OrchestratorLogger.logWarning(`[Azure ACI] Polling error: ${error.message}`);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error('[Azure ACI] Container execution timed out after 24 hours');
}
async cleanupWorkflow(
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
OrchestratorLogger.log('[Azure ACI] Cleaning up workflow');
}
async garbageCollect(
filter: string,
previewOnly: boolean,
olderThan: Number,
fullCache: boolean,
baseDependencies: boolean,
): Promise<string> {
OrchestratorLogger.log('[Azure ACI] Garbage collecting old container groups');
try {
const containersJson = await OrchestratorSystem.Run(
`az container list --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
const containers = JSON.parse(containersJson || '[]');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - Number(olderThan));
let deletedCount = 0;
for (const container of containers) {
const name = container.name || '';
if (!name.startsWith('unity-build-')) continue;
const createdAt = new Date(container.tags?.createdAt || container.properties?.provisioningState || 0);
const state = container.containers?.[0]?.instanceView?.currentState?.state || '';
if (state === 'Terminated' || createdAt < cutoffDate) {
if (previewOnly) {
OrchestratorLogger.log(`[Azure ACI] Would delete: ${name}`);
} else {
await OrchestratorSystem.Run(
`az container delete --resource-group "${this.resourceGroup}" --name "${name}" --yes`,
);
deletedCount++;
}
}
}
return `Garbage collected ${deletedCount} Azure container instances`;
} catch (error: any) {
OrchestratorLogger.logWarning(`[Azure ACI] Garbage collection failed: ${error.message}`);
return '';
}
}
async listResources(): Promise<ProviderResource[]> {
try {
const containersJson = await OrchestratorSystem.Run(
`az container list --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
const containers = JSON.parse(containersJson || '[]');
return containers
.filter((c: any) => (c.name || '').startsWith('unity-build-'))
.map((c: any) => ({ Name: c.name || '' }));
} catch {
return [];
}
}
listWorkflow(): Promise<ProviderWorkflow[]> {
throw new Error('[Azure ACI] listWorkflow not implemented for this experimental provider');
}
async watchWorkflow(): Promise<string> {
throw new Error('[Azure ACI] watchWorkflow not implemented for this experimental provider');
}
}
export default AzureAciProvider;

View File

@@ -1,20 +0,0 @@
export interface CliProviderRequest {
command: CliProviderSubcommand;
params: Record<string, any>;
}
export interface CliProviderResponse {
success: boolean;
result?: any;
error?: string;
output?: string;
}
export type CliProviderSubcommand =
| 'setup-workflow'
| 'cleanup-workflow'
| 'run-task'
| 'garbage-collect'
| 'list-resources'
| 'list-workflow'
| 'watch-workflow';

View File

@@ -1,532 +0,0 @@
import { EventEmitter } from 'events';
import { ProviderLoader } from '../provider-loader';
// Mock child_process
jest.mock('child_process', () => ({
spawn: jest.fn(),
exec: jest.fn(),
}));
// Mock @actions/core to prevent GitHub Actions API calls
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(() => ''),
}));
// Mock provider-git-manager (required by provider-loader)
jest.mock('../provider-git-manager');
import { spawn } from 'child_process';
import * as core from '@actions/core';
import CliProvider from './cli-provider';
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
/**
* Creates a mock child process with stdin, stdout, stderr as EventEmitters.
*/
function createMockChildProcess() {
const child = new EventEmitter() as any;
child.stdin = { write: jest.fn(), end: jest.fn() };
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = jest.fn();
return child;
}
describe('CliProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it('validates that executable path is non-empty', () => {
expect(() => new CliProvider('', {} as any)).toThrow('executablePath must be a non-empty string');
});
it('validates that executable path is not just whitespace', () => {
expect(() => new CliProvider(' ', {} as any)).toThrow('executablePath must be a non-empty string');
});
it('accepts a valid executable path', () => {
const provider = new CliProvider('/usr/bin/my-provider', {} as any);
expect(provider).toBeDefined();
});
});
describe('request serialization', () => {
it('sends JSON request to stdin with correct command and params', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listResources();
// Simulate successful response
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: [] }) + '\n'));
child.emit('close', 0);
await promise;
expect(child.stdin.write).toHaveBeenCalledTimes(1);
const writtenData = child.stdin.write.mock.calls[0][0];
const parsed = JSON.parse(writtenData);
expect(parsed.command).toBe('list-resources');
expect(parsed.params).toEqual({});
expect(child.stdin.end).toHaveBeenCalled();
});
it('serializes setupWorkflow params correctly', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.setupWorkflow('guid-123', { editorVersion: '2022.3' } as any, 'main', []);
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: {} }) + '\n'));
child.emit('close', 0);
await promise;
const writtenData = child.stdin.write.mock.calls[0][0];
const parsed = JSON.parse(writtenData);
expect(parsed.command).toBe('setup-workflow');
expect(parsed.params.buildGuid).toBe('guid-123');
expect(parsed.params.branchName).toBe('main');
});
});
describe('response parsing', () => {
it('resolves on successful JSON response', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listResources();
const resources = [{ Name: 'resource-1' }, { Name: 'resource-2' }];
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: resources }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toEqual(resources);
});
it('rejects on error JSON response', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.garbageCollect('', false, 30, false, false);
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: false, error: 'something went wrong' }) + '\n'));
child.emit('close', 1);
await expect(promise).rejects.toThrow('something went wrong');
});
it('rejects when process exits with non-zero code and no JSON response', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listWorkflow();
child.stderr.emit('data', Buffer.from('segfault\n'));
child.emit('close', 139);
await expect(promise).rejects.toThrow('exited with code 139');
});
it('resolves when process exits with code 0 and no JSON response', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listResources();
child.stdout.emit('data', Buffer.from('some plain text output\n'));
child.emit('close', 0);
const result = await promise;
// listResources falls back to empty array when result is missing
expect(result).toEqual([]);
});
it('rejects on spawn error', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/nonexistent/path', {} as any);
const promise = provider.listResources();
child.emit('error', new Error('ENOENT'));
await expect(promise).rejects.toThrow('failed to spawn executable');
});
});
describe('runTaskInWorkflow', () => {
it('forwards non-JSON stdout lines as build output and returns final response', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
// Simulate build output followed by JSON response
child.stdout.emit('data', Buffer.from('Building project...\nCompiling scripts...\n'));
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: 'Build succeeded' }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('Build succeeded');
});
it('rejects on run-task failure', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
child.stdout.emit(
'data',
Buffer.from(JSON.stringify({ success: false, error: 'Build failed: compilation errors' }) + '\n'),
);
child.emit('close', 1);
await expect(promise).rejects.toThrow('Build failed: compilation errors');
});
it('returns collected output lines when no JSON response and exit code 0', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
child.stdout.emit('data', Buffer.from('line 1\nline 2\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('line 1\nline 2');
});
it('rejects on spawn error', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/nonexistent/path', {} as any);
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
child.emit('error', new Error('ENOENT'));
await expect(promise).rejects.toThrow('failed to spawn executable');
});
});
describe('cleanupWorkflow', () => {
it('sends cleanup-workflow command and returns result', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.cleanupWorkflow({ editorVersion: '2022.3' } as any, 'main', []);
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: 'cleaned' }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('cleaned');
const parsed = JSON.parse(child.stdin.write.mock.calls[0][0]);
expect(parsed.command).toBe('cleanup-workflow');
expect(parsed.params.branchName).toBe('main');
});
});
describe('garbageCollect', () => {
it('sends garbage-collect command with correct params', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.garbageCollect('filter*', true, 30, false, true);
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: '3 items removed' }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('3 items removed');
const parsed = JSON.parse(child.stdin.write.mock.calls[0][0]);
expect(parsed.command).toBe('garbage-collect');
expect(parsed.params.filter).toBe('filter*');
expect(parsed.params.previewOnly).toBe(true);
expect(parsed.params.olderThan).toBe(30);
expect(parsed.params.baseDependencies).toBe(true);
});
it('returns empty string when response has no output', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.garbageCollect('', false, 7, false, false);
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('');
});
});
describe('listWorkflow', () => {
it('returns workflow list from response', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listWorkflow();
const workflows = [{ Name: 'wf-1' }];
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: workflows }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toEqual(workflows);
});
it('returns empty array when result is missing', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listWorkflow();
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toEqual([]);
});
});
describe('watchWorkflow', () => {
it('forwards streaming output and resolves on success', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.watchWorkflow();
child.stdout.emit('data', Buffer.from('watching...\nstatus: running\n'));
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: 'completed' }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('completed');
const parsed = JSON.parse(child.stdin.write.mock.calls[0][0]);
expect(parsed.command).toBe('watch-workflow');
});
it('rejects on watch-workflow failure', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.watchWorkflow();
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: false, error: 'lost connection' }) + '\n'));
child.emit('close', 1);
await expect(promise).rejects.toThrow('lost connection');
});
it('rejects on spawn error', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.watchWorkflow();
child.emit('error', new Error('ENOENT'));
await expect(promise).rejects.toThrow('failed to spawn executable');
});
it('resolves with collected output when exit code 0 and no JSON', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.watchWorkflow();
child.stdout.emit('data', Buffer.from('line A\nline B\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('line A\nline B');
});
});
describe('stderr forwarding', () => {
it('forwards stderr lines to logger during execute', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listResources();
child.stderr.emit('data', Buffer.from('warning: something\n'));
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: [] }) + '\n'));
child.emit('close', 0);
await promise;
// stderr content included in error message if process fails
// Here it succeeds, so we just verify no rejection
});
it('includes stderr in error message when process fails without JSON', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listResources();
child.stderr.emit('data', Buffer.from('fatal: segfault\n'));
child.emit('close', 1);
await expect(promise).rejects.toThrow('fatal: segfault');
});
});
describe('timeout handling', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('rejects and kills process when execute command times out', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.listResources();
// Advance past the 300s default timeout
jest.advanceTimersByTime(301_000);
await expect(promise).rejects.toThrow('timed out');
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
});
it('rejects and kills process when runTaskInWorkflow times out', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
// Advance past the 2-hour timeout (7_200_000ms)
jest.advanceTimersByTime(7_200_001);
await expect(promise).rejects.toThrow('run-task timed out');
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('CLI provider timed out after 120 minutes'));
});
it('rejects and kills process when watchWorkflow times out', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.watchWorkflow();
// Advance past the 1-hour timeout (3_600_000ms)
jest.advanceTimersByTime(3_600_001);
await expect(promise).rejects.toThrow('watch-workflow timed out');
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('CLI provider timed out after 60 minutes'));
});
it('escalates to SIGKILL after grace period on runTaskInWorkflow timeout', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
// Trigger the timeout
jest.advanceTimersByTime(7_200_001);
await expect(promise).rejects.toThrow('timed out');
// SIGTERM was sent
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
// Advance past the 10s grace period — SIGKILL should fire
jest.advanceTimersByTime(10_001);
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
});
it('does not send SIGKILL if process exits before grace period', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
// Trigger the timeout
jest.advanceTimersByTime(7_200_001);
await expect(promise).rejects.toThrow('timed out');
// Process exits voluntarily after SIGTERM
child.emit('close', 143);
// Advance past the grace period — SIGKILL should NOT fire because process already exited
jest.advanceTimersByTime(10_001);
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
// SIGKILL should not have been called because the close event cleared the timer
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
});
it('clears timeout when runTaskInWorkflow completes normally', async () => {
const child = createMockChildProcess();
mockSpawn.mockReturnValue(child);
const provider = new CliProvider('/path/to/exe', {} as any);
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
// Process completes before timeout
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: 'done' }) + '\n'));
child.emit('close', 0);
const result = await promise;
expect(result).toBe('done');
// Advance far past timeout — should NOT reject
jest.advanceTimersByTime(8_000_000);
expect(child.kill).not.toHaveBeenCalled();
});
});
describe('available providers list', () => {
it('includes cli in the available providers', () => {
const providers = ProviderLoader.getAvailableProviders();
expect(providers).toContain('cli');
});
});
});

View File

@@ -1,406 +0,0 @@
import { spawn, ChildProcess } from 'child_process';
import * as core from '@actions/core';
import { ProviderInterface } from '../provider-interface';
import BuildParameters from '../../../build-parameters';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { CliProviderRequest, CliProviderResponse, CliProviderSubcommand } from './cli-provider-protocol';
const DEFAULT_TIMEOUT_MS = 300_000; // 300 seconds
const RUN_TASK_TIMEOUT_MS = 7_200_000; // 2 hours
const WATCH_WORKFLOW_TIMEOUT_MS = 3_600_000; // 1 hour
const SIGKILL_GRACE_MS = 10_000; // 10 seconds grace period before SIGKILL
/**
* Gracefully kill a child process: SIGTERM first, then SIGKILL after a grace period.
*/
function gracefulKill(child: ChildProcess, graceMs: number = SIGKILL_GRACE_MS): void {
child.kill('SIGTERM');
const forceKillTimer = setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {
// Process may already be dead
}
}, graceMs);
// Clear the force-kill timer if the process exits on its own
child.on('close', () => {
clearTimeout(forceKillTimer);
});
}
class CliProvider implements ProviderInterface {
private readonly executablePath: string;
private readonly buildParameters: BuildParameters;
constructor(executablePath: string, buildParameters: BuildParameters) {
if (!executablePath || executablePath.trim() === '') {
throw new Error('CliProvider: executablePath must be a non-empty string');
}
this.executablePath = executablePath;
this.buildParameters = buildParameters;
}
async setupWorkflow(
buildGuid: string,
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<any> {
const response = await this.execute('setup-workflow', {
buildGuid,
buildParameters,
branchName,
defaultSecretsArray,
});
return response.result;
}
async cleanupWorkflow(
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<any> {
const response = await this.execute('cleanup-workflow', {
buildParameters,
branchName,
defaultSecretsArray,
});
return response.result;
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
const request: CliProviderRequest = {
command: 'run-task',
params: {
buildGuid,
image,
commands,
mountdir,
workingdir,
environment,
secrets,
},
};
const timeoutMs = RUN_TASK_TIMEOUT_MS;
return new Promise<string>((resolve, reject) => {
const child = spawn(this.executablePath, ['run-task'], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
let lastJsonResponse: CliProviderResponse | undefined;
const outputLines: string[] = [];
let stderrOutput = '';
let timedOut = false;
// Set up timeout to prevent indefinite hangs
const timer = setTimeout(() => {
timedOut = true;
const minutes = Math.round(timeoutMs / 60_000);
const message = `CLI provider timed out after ${minutes} minutes. The external provider may be unresponsive.`;
core.error(message);
gracefulKill(child);
reject(new Error(`CliProvider run-task timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdin.write(JSON.stringify(request));
child.stdin.end();
child.stdout.on('data', (data: Buffer) => {
const lines = data.toString().split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Try to parse as JSON response
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
lastJsonResponse = parsed as CliProviderResponse;
continue;
}
} catch {
// Not JSON — treat as build output
}
// Forward non-JSON lines as real-time build output
OrchestratorLogger.log(trimmed);
outputLines.push(trimmed);
}
});
child.stderr.on('data', (data: Buffer) => {
const text = data.toString();
stderrOutput += text;
// Forward stderr to logger
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (trimmed) {
OrchestratorLogger.log(`[cli-provider stderr] ${trimmed}`);
}
}
});
child.on('error', (error: Error) => {
clearTimeout(timer);
if (!timedOut) {
reject(new Error(`CliProvider: failed to spawn executable '${this.executablePath}': ${error.message}`));
}
});
child.on('close', (code: number | null) => {
clearTimeout(timer);
if (timedOut) return;
if (lastJsonResponse) {
if (lastJsonResponse.success) {
resolve(lastJsonResponse.output || outputLines.join('\n'));
} else {
reject(
new Error(`CliProvider run-task failed: ${lastJsonResponse.error || 'Unknown error from CLI provider'}`),
);
}
} else if (code === 0) {
resolve(outputLines.join('\n'));
} else {
reject(
new Error(`CliProvider run-task exited with code ${code}${stderrOutput ? ': ' + stderrOutput.trim() : ''}`),
);
}
});
});
}
async garbageCollect(
filter: string,
previewOnly: boolean,
olderThan: Number,
fullCache: boolean,
baseDependencies: boolean,
): Promise<string> {
const response = await this.execute('garbage-collect', {
filter,
previewOnly,
olderThan,
fullCache,
baseDependencies,
});
return response.output || '';
}
async listResources(): Promise<ProviderResource[]> {
const response = await this.execute('list-resources', {});
return (response.result as ProviderResource[]) || [];
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
const response = await this.execute('list-workflow', {});
return (response.result as ProviderWorkflow[]) || [];
}
async watchWorkflow(): Promise<string> {
const request: CliProviderRequest = {
command: 'watch-workflow',
params: {},
};
const timeoutMs = WATCH_WORKFLOW_TIMEOUT_MS;
return new Promise<string>((resolve, reject) => {
const child = spawn(this.executablePath, ['watch-workflow'], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
let lastJsonResponse: CliProviderResponse | undefined;
const outputLines: string[] = [];
let timedOut = false;
// Set up timeout to prevent indefinite hangs
const timer = setTimeout(() => {
timedOut = true;
const minutes = Math.round(timeoutMs / 60_000);
const message = `CLI provider timed out after ${minutes} minutes. The external provider may be unresponsive.`;
core.error(message);
gracefulKill(child);
reject(new Error(`CliProvider watch-workflow timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdin.write(JSON.stringify(request));
child.stdin.end();
child.stdout.on('data', (data: Buffer) => {
const lines = data.toString().split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
lastJsonResponse = parsed as CliProviderResponse;
continue;
}
} catch {
// Not JSON
}
OrchestratorLogger.log(trimmed);
outputLines.push(trimmed);
}
});
child.stderr.on('data', (data: Buffer) => {
for (const line of data.toString().split('\n')) {
const trimmed = line.trim();
if (trimmed) {
OrchestratorLogger.log(`[cli-provider stderr] ${trimmed}`);
}
}
});
child.on('error', (error: Error) => {
clearTimeout(timer);
if (!timedOut) {
reject(new Error(`CliProvider: failed to spawn executable '${this.executablePath}': ${error.message}`));
}
});
child.on('close', (code: number | null) => {
clearTimeout(timer);
if (timedOut) return;
if (lastJsonResponse) {
if (lastJsonResponse.success) {
resolve(lastJsonResponse.output || outputLines.join('\n'));
} else {
reject(new Error(`CliProvider watch-workflow failed: ${lastJsonResponse.error || 'Unknown error'}`));
}
} else if (code === 0) {
resolve(outputLines.join('\n'));
} else {
reject(new Error(`CliProvider watch-workflow exited with code ${code}`));
}
});
});
}
/**
* Execute a CLI provider subcommand with a default timeout.
* Timeout applies a graceful SIGTERM followed by SIGKILL after a grace period.
*/
private execute(
command: CliProviderSubcommand,
params: Record<string, any>,
timeoutMs: number = DEFAULT_TIMEOUT_MS,
): Promise<CliProviderResponse> {
const request: CliProviderRequest = { command, params };
return new Promise<CliProviderResponse>((resolve, reject) => {
const child = spawn(this.executablePath, [command], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
let stdoutData = '';
let stderrData = '';
let timedOut = false;
// Set up timeout with graceful kill
const timer = setTimeout(() => {
timedOut = true;
gracefulKill(child);
reject(new Error(`CliProvider: command '${command}' timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdin.write(JSON.stringify(request));
child.stdin.end();
child.stdout.on('data', (data: Buffer) => {
stdoutData += data.toString();
});
child.stderr.on('data', (data: Buffer) => {
const text = data.toString();
stderrData += text;
// Forward stderr to logger
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (trimmed) {
OrchestratorLogger.log(`[cli-provider stderr] ${trimmed}`);
}
}
});
child.on('error', (error: Error) => {
clearTimeout(timer);
if (!timedOut) {
reject(new Error(`CliProvider: failed to spawn executable '${this.executablePath}': ${error.message}`));
}
});
child.on('close', (code: number | null) => {
clearTimeout(timer);
if (timedOut) return;
// Find the last JSON line in stdout
const lines = stdoutData.split('\n').filter((l) => l.trim());
let response: CliProviderResponse | undefined;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i].trim());
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
response = parsed as CliProviderResponse;
break;
}
} catch {
// Not valid JSON, skip
}
}
if (response) {
if (response.success) {
resolve(response);
} else {
reject(new Error(`CliProvider ${command} failed: ${response.error || 'Unknown error from CLI provider'}`));
}
} else if (code === 0) {
// No JSON response but exit code 0 — treat as success with raw output
resolve({ success: true, output: stdoutData.trim() });
} else {
reject(
new Error(
`CliProvider ${command} exited with code ${code}` +
(stderrData ? `: ${stderrData.trim()}` : '') +
(!stderrData && stdoutData ? `: ${stdoutData.trim()}` : ''),
),
);
}
});
});
}
}
export default CliProvider;

View File

@@ -1 +0,0 @@
export { default } from './cli-provider';

View File

@@ -1,435 +0,0 @@
/**
* Google Cloud Run Jobs Provider (Experimental)
*
* Executes Unity builds as Cloud Run Jobs with configurable storage backends.
*
* Storage types:
* - gcs-fuse: Mount a GCS bucket as a POSIX filesystem via GCS FUSE sidecar.
* Unlimited size, best for large sequential reads/writes.
* Requires: gcpBucket
* - gcs-copy: Copy artifacts in/out of GCS before/after the build via gsutil.
* No mount overhead, simpler, works everywhere.
* Requires: gcpBucket
* - nfs: Mount a Filestore NFS share. True POSIX semantics, good random I/O,
* up to 100 TiB. Best for Library caching (many small random reads).
* Requires: gcpFilestoreIp, gcpFilestoreShare
* - in-memory: tmpfs volume (emptyDir). Fastest I/O but volatile and limited to 32 GiB.
* Good for scratch/temp space during builds.
*
* Prerequisites:
* - Google Cloud SDK authenticated (GOOGLE_APPLICATION_CREDENTIALS or gcloud auth)
* - Cloud Run Jobs API enabled
* - Service account with roles: Cloud Run Admin, Storage Admin, Logs Viewer
*
* @experimental This provider is experimental. APIs and behavior may change.
*/
import { ProviderInterface } from '../provider-interface';
import BuildParameters from '../../../build-parameters';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import { Input } from '../../..';
import ResourceTracking from '../../services/core/resource-tracking';
type GcpStorageType = 'gcs-fuse' | 'gcs-copy' | 'nfs' | 'in-memory';
class GcpCloudRunProvider implements ProviderInterface {
private readonly project: string;
private readonly region: string;
private readonly storageType: GcpStorageType;
private readonly bucket: string;
private readonly filestoreIp: string;
private readonly filestoreShare: string;
private readonly machineType: string;
private readonly diskSizeGb: number;
private readonly serviceAccount: string;
private readonly vpcConnector: string;
private buildParameters: BuildParameters;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.project = buildParameters.gcpProject || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || '';
this.region = buildParameters.gcpRegion || Input.region || 'us-central1';
this.storageType = (buildParameters.gcpStorageType || 'gcs-fuse') as GcpStorageType;
this.bucket = buildParameters.gcpBucket || '';
this.filestoreIp = buildParameters.gcpFilestoreIp || '';
this.filestoreShare = buildParameters.gcpFilestoreShare || '/share1';
this.machineType = buildParameters.gcpMachineType || 'e2-standard-4';
this.diskSizeGb = Number.parseInt(buildParameters.gcpDiskSizeGb || '100', 10);
this.serviceAccount = buildParameters.gcpServiceAccount || '';
this.vpcConnector = buildParameters.gcpVpcConnector || '';
OrchestratorLogger.log('[GCP Cloud Run] Provider initialized (EXPERIMENTAL)');
OrchestratorLogger.log(`[GCP Cloud Run] Project: ${this.project || '(auto-detect)'}`);
OrchestratorLogger.log(`[GCP Cloud Run] Region: ${this.region}`);
OrchestratorLogger.log(`[GCP Cloud Run] Storage: ${this.storageType}`);
this.validateStorageConfig();
}
private validateStorageConfig(): void {
switch (this.storageType) {
case 'gcs-fuse':
case 'gcs-copy':
if (!this.bucket) {
OrchestratorLogger.logWarning(
`[GCP Cloud Run] Storage type '${this.storageType}' requires gcpBucket to be set.`,
);
} else {
OrchestratorLogger.log(`[GCP Cloud Run] Bucket: gs://${this.bucket}`);
}
break;
case 'nfs':
if (!this.filestoreIp) {
OrchestratorLogger.logWarning('[GCP Cloud Run] Storage type "nfs" requires gcpFilestoreIp to be set.');
} else {
OrchestratorLogger.log(`[GCP Cloud Run] Filestore: ${this.filestoreIp}:${this.filestoreShare}`);
}
if (!this.vpcConnector) {
OrchestratorLogger.logWarning(
'[GCP Cloud Run] NFS storage usually requires gcpVpcConnector for private network access to Filestore.',
);
}
break;
case 'in-memory':
OrchestratorLogger.log(`[GCP Cloud Run] In-memory volume: ${Math.min(this.diskSizeGb, 32)} GiB (max 32)`);
break;
default:
OrchestratorLogger.logWarning(
`[GCP Cloud Run] Unknown storage type '${this.storageType}'. Valid: gcs-fuse, gcs-copy, nfs, in-memory`,
);
}
if (!this.project) {
OrchestratorLogger.logWarning(
'[GCP Cloud Run] No project specified. Set gcpProject input or GOOGLE_CLOUD_PROJECT env var.',
);
}
}
async setupWorkflow(
buildGuid: string,
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
OrchestratorLogger.log(`[GCP Cloud Run] Setting up workflow for build ${buildGuid}`);
ResourceTracking.logAllocationSummary('gcp-cloud-run setup');
// Verify gcloud CLI is available
try {
await OrchestratorSystem.Run('gcloud --version', false, true);
OrchestratorLogger.log('[GCP Cloud Run] gcloud CLI detected');
} catch {
throw new Error(
'[GCP Cloud Run] gcloud CLI not found. Install Google Cloud SDK: https://cloud.google.com/sdk/docs/install',
);
}
// Verify Cloud Run Jobs API is enabled
try {
const projectFlag = this.project ? `--project=${this.project}` : '';
await OrchestratorSystem.Run(
`gcloud services list --enabled --filter="name:run.googleapis.com" ${projectFlag} --format="value(name)"`,
false,
true,
);
} catch {
OrchestratorLogger.logWarning(
'[GCP Cloud Run] Could not verify Cloud Run API status. Ensure run.googleapis.com is enabled.',
);
}
// Storage-specific setup
if ((this.storageType === 'gcs-fuse' || this.storageType === 'gcs-copy') && this.bucket) {
await this.ensureBucketExists();
}
}
private async ensureBucketExists(): Promise<void> {
try {
await OrchestratorSystem.Run(
`gcloud storage buckets describe gs://${this.bucket} --format="value(name)"`,
false,
true,
);
OrchestratorLogger.log(`[GCP Cloud Run] Bucket gs://${this.bucket} exists`);
} catch {
OrchestratorLogger.log(`[GCP Cloud Run] Creating bucket gs://${this.bucket}`);
const projectFlag = this.project ? `--project=${this.project}` : '';
await OrchestratorSystem.Run(
`gcloud storage buckets create gs://${this.bucket} --location=${this.region} ${projectFlag}`,
);
}
}
private buildVolumeFlags(mountdir: string): { volumeFlags: string; mountFlags: string } {
switch (this.storageType) {
case 'gcs-fuse':
if (!this.bucket) return { volumeFlags: '', mountFlags: '' };
return {
volumeFlags: `--add-volume=name=gcs-fuse,type=cloud-storage,bucket=${this.bucket}`,
mountFlags: `--add-volume-mount=volume=gcs-fuse,mount-path=${mountdir}`,
};
case 'nfs':
if (!this.filestoreIp) return { volumeFlags: '', mountFlags: '' };
return {
volumeFlags: `--add-volume=name=nfs-vol,type=nfs,location=${this.filestoreIp}:${this.filestoreShare}`,
mountFlags: `--add-volume-mount=volume=nfs-vol,mount-path=${mountdir}`,
};
case 'in-memory': {
const sizeGib = Math.min(this.diskSizeGb, 32);
return {
volumeFlags: `--add-volume=name=tmpfs-vol,type=in-memory,size-limit=${sizeGib}Gi`,
mountFlags: `--add-volume-mount=volume=tmpfs-vol,mount-path=${mountdir}`,
};
}
case 'gcs-copy':
// No volume mount — artifacts are copied in/out via gsutil commands
return { volumeFlags: '', mountFlags: '' };
default:
return { volumeFlags: '', mountFlags: '' };
}
}
private async copyArtifactsIn(mountdir: string): Promise<void> {
if (this.storageType !== 'gcs-copy' || !this.bucket) return;
OrchestratorLogger.log(`[GCP Cloud Run] Copying artifacts from gs://${this.bucket} to ${mountdir}`);
try {
await OrchestratorSystem.Run(`gcloud storage cp -r "gs://${this.bucket}/*" "${mountdir}/" || true`, false, true);
} catch {
OrchestratorLogger.log('[GCP Cloud Run] No existing artifacts to restore (bucket may be empty)');
}
}
private async copyArtifactsOut(mountdir: string): Promise<void> {
if (this.storageType !== 'gcs-copy' || !this.bucket) return;
OrchestratorLogger.log(`[GCP Cloud Run] Uploading artifacts from ${mountdir} to gs://${this.bucket}`);
await OrchestratorSystem.Run(`gcloud storage cp -r "${mountdir}/*" "gs://${this.bucket}/"`, false, true);
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[GCP Cloud Run] Running task for build ${buildGuid}`);
ResourceTracking.logAllocationSummary('gcp-cloud-run task');
const jobName = `unity-build-${buildGuid}`
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.slice(0, 63);
const projectFlag = this.project ? `--project=${this.project}` : '';
// Build environment variable flags
const envFlags = environment
.map((env) => `${env.name}=${env.value}`)
.concat(secrets.map((s) => `${s.EnvironmentVariable}=${s.ParameterValue}`));
const envString = envFlags.length > 0 ? `--set-env-vars="${envFlags.join(',')}"` : '';
// Build storage volume flags
const { volumeFlags, mountFlags } = this.buildVolumeFlags(mountdir);
// For gcs-copy, wrap the user command with copy-in/copy-out steps
let effectiveCommands = commands;
if (this.storageType === 'gcs-copy' && this.bucket && commands) {
effectiveCommands = [
`gcloud storage cp -r "gs://${this.bucket}/*" "${mountdir}/" 2>/dev/null || true`,
commands,
`gcloud storage cp -r "${mountdir}/*" "gs://${this.bucket}/"`,
].join(' && ');
}
const saFlag = this.serviceAccount ? `--service-account=${this.serviceAccount}` : '';
const vpcFlag = this.vpcConnector ? `--vpc-connector=${this.vpcConnector}` : '';
// Create the Cloud Run Job
const createCmd = [
'gcloud run jobs create',
jobName,
`--image=${image}`,
`--region=${this.region}`,
'--task-timeout=86400s',
'--max-retries=0',
'--cpu=4',
'--memory=16Gi',
volumeFlags,
mountFlags,
envString,
saFlag,
vpcFlag,
projectFlag,
'--format=json',
'--quiet',
]
.filter(Boolean)
.join(' ');
try {
await OrchestratorSystem.Run(createCmd);
OrchestratorLogger.log(`[GCP Cloud Run] Job ${jobName} created`);
} catch (error: any) {
if (error.message?.includes('already exists')) {
OrchestratorLogger.log(`[GCP Cloud Run] Job ${jobName} already exists, updating...`);
const updateCmd = createCmd.replace('jobs create', 'jobs update');
await OrchestratorSystem.Run(updateCmd);
} else {
throw error;
}
}
// Override the command if provided
if (effectiveCommands) {
const updateCmd = [
'gcloud run jobs update',
jobName,
`--region=${this.region}`,
'--command="/bin/sh"',
`--args="-c,${effectiveCommands}"`,
projectFlag,
'--quiet',
]
.filter(Boolean)
.join(' ');
await OrchestratorSystem.Run(updateCmd);
}
// Execute the job
OrchestratorLogger.log(`[GCP Cloud Run] Executing job ${jobName} (storage: ${this.storageType})...`);
const executeCmd = [
'gcloud run jobs execute',
jobName,
`--region=${this.region}`,
projectFlag,
'--wait',
'--format=json',
'--quiet',
]
.filter(Boolean)
.join(' ');
let output = '';
try {
output = await OrchestratorSystem.Run(executeCmd);
OrchestratorLogger.log('[GCP Cloud Run] Job execution completed');
} catch (error: any) {
await this.streamJobLogs(jobName);
throw new Error(`[GCP Cloud Run] Job execution failed: ${error.message}`);
}
await this.streamJobLogs(jobName);
return output;
}
private async streamJobLogs(jobName: string): Promise<void> {
const projectFlag = this.project ? `--project=${this.project}` : '';
try {
const logs = await OrchestratorSystem.Run(
`gcloud logging read "resource.type=cloud_run_job AND resource.labels.job_name=${jobName}" ${projectFlag} --limit=1000 --format="value(textPayload)" --order=asc`,
false,
true,
);
if (logs) {
for (const line of logs.split('\n')) {
if (line.trim()) {
OrchestratorLogger.log(`[Build] ${line}`);
}
}
}
} catch {
OrchestratorLogger.logWarning('[GCP Cloud Run] Could not retrieve job logs');
}
}
async cleanupWorkflow(
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
OrchestratorLogger.log('[GCP Cloud Run] Cleaning up workflow');
}
async garbageCollect(
filter: string,
previewOnly: boolean,
olderThan: Number,
fullCache: boolean,
baseDependencies: boolean,
): Promise<string> {
OrchestratorLogger.log('[GCP Cloud Run] Garbage collecting old jobs');
const projectFlag = this.project ? `--project=${this.project}` : '';
try {
const jobsJson = await OrchestratorSystem.Run(
`gcloud run jobs list --region=${this.region} ${projectFlag} --filter="metadata.name~unity-build-" --format="json(metadata.name,metadata.creationTimestamp)"`,
false,
true,
);
const jobs = JSON.parse(jobsJson || '[]');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - Number(olderThan));
let deletedCount = 0;
for (const job of jobs) {
const createdAt = new Date(job.metadata?.creationTimestamp || 0);
if (createdAt < cutoffDate) {
const name = job.metadata?.name;
if (previewOnly) {
OrchestratorLogger.log(`[GCP Cloud Run] Would delete: ${name}`);
} else {
await OrchestratorSystem.Run(
`gcloud run jobs delete ${name} --region=${this.region} ${projectFlag} --quiet`,
);
deletedCount++;
}
}
}
return `Garbage collected ${deletedCount} Cloud Run jobs`;
} catch (error: any) {
OrchestratorLogger.logWarning(`[GCP Cloud Run] Garbage collection failed: ${error.message}`);
return '';
}
}
async listResources(): Promise<ProviderResource[]> {
const projectFlag = this.project ? `--project=${this.project}` : '';
try {
const jobsJson = await OrchestratorSystem.Run(
`gcloud run jobs list --region=${this.region} ${projectFlag} --filter="metadata.name~unity-build-" --format="json(metadata.name)"`,
false,
true,
);
const jobs = JSON.parse(jobsJson || '[]');
return jobs.map((job: any) => ({ Name: job.metadata?.name || '' }));
} catch {
return [];
}
}
listWorkflow(): Promise<ProviderWorkflow[]> {
throw new Error('[GCP Cloud Run] listWorkflow not implemented for this experimental provider');
}
async watchWorkflow(): Promise<string> {
throw new Error('[GCP Cloud Run] watchWorkflow not implemented for this experimental provider');
}
}
export default GcpCloudRunProvider;

View File

@@ -1,333 +0,0 @@
import GitHubActionsProvider from '.';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import * as core from '@actions/core';
jest.mock('../../services/core/orchestrator-system');
jest.mock('../../services/core/orchestrator-logger');
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(() => ''),
}));
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
function createBuildParameters(overrides: Partial<BuildParameters> = {}): BuildParameters {
return {
githubActionsRepo: 'owner/repo',
githubActionsWorkflow: 'build.yml',
githubActionsToken: 'ghp_test_token_123',
githubActionsRef: 'main',
...overrides,
} as BuildParameters;
}
// Override setTimeout to execute callbacks immediately so polling loops complete fast
const originalSetTimeout = global.setTimeout;
beforeAll(() => {
global.setTimeout = ((fn: (...args: any[]) => void, _ms?: number, ...args: any[]) => {
return originalSetTimeout(fn, 0, ...args);
}) as any;
});
afterAll(() => {
global.setTimeout = originalSetTimeout;
});
describe('GitHubActionsProvider', () => {
let provider: GitHubActionsProvider;
beforeEach(() => {
jest.clearAllMocks();
provider = new GitHubActionsProvider(createBuildParameters());
});
describe('constructor', () => {
it('sets default ref to main when not specified', () => {
const params = createBuildParameters({ githubActionsRef: undefined });
const p = new GitHubActionsProvider(params);
expect(p).toBeDefined();
});
it('uses provided ref when specified', () => {
const params = createBuildParameters({ githubActionsRef: 'develop' });
const p = new GitHubActionsProvider(params);
expect(p).toBeDefined();
});
});
describe('setupWorkflow', () => {
it('verifies workflow exists via gh api and logs success', async () => {
mockRun.mockResolvedValueOnce('12345\n');
await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []);
expect(mockRun).toHaveBeenCalledTimes(1);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('gh api repos/owner/repo/actions/workflows/build.yml');
expect(command).toContain("--jq '.id'");
expect(command).toContain('GH_TOKEN=ghp_test_token_123');
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Workflow verified'));
});
it('throws when repo is not configured', async () => {
const params = createBuildParameters({ githubActionsRepo: '' });
provider = new GitHubActionsProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'githubActionsRepo and githubActionsWorkflow are required',
);
});
it('throws when workflow is not configured', async () => {
const params = createBuildParameters({ githubActionsWorkflow: '' });
provider = new GitHubActionsProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'githubActionsRepo and githubActionsWorkflow are required',
);
});
it('throws when token is missing', async () => {
const params = createBuildParameters({ githubActionsToken: '' });
provider = new GitHubActionsProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'githubActionsToken is required',
);
});
it('throws descriptive error when workflow verification fails', async () => {
mockRun.mockRejectedValueOnce(new Error('Not Found'));
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
'Failed to verify workflow build.yml in owner/repo',
);
});
});
describe('runTaskInWorkflow', () => {
it('dispatches workflow with correct inputs and returns logs on success', async () => {
// Dispatch succeeds
mockRun.mockResolvedValueOnce('');
// First poll finds the run
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 99001, status: 'in_progress' }));
// Status poll returns completed
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' }));
// Log fetch succeeds
mockRun.mockResolvedValueOnce('Build output log content here');
const result = await provider.runTaskInWorkflow(
'guid-abc',
'unityci/editor:2021.3',
'echo build',
'/mount',
'/work',
[],
[],
);
expect(result).toBe('Build output log content here');
// Verify dispatch command
const dispatchCommand = mockRun.mock.calls[0][0];
expect(dispatchCommand).toContain('dispatches');
expect(dispatchCommand).toContain('-X POST');
expect(dispatchCommand).toContain("ref='main'");
// Verify log fetch command
const logCommand = mockRun.mock.calls[3][0];
expect(logCommand).toContain('gh run view');
expect(logCommand).toContain('--log');
expect(logCommand).toContain('--repo owner/repo');
});
it('base64 encodes commands in the inputs payload', async () => {
mockRun.mockResolvedValueOnce(''); // dispatch
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 100, status: 'completed' })); // run found
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' })); // status
mockRun.mockResolvedValueOnce('logs'); // logs
await provider.runTaskInWorkflow('guid-1', 'image:latest', 'echo hello && build', '/mnt', '/w', [], []);
const dispatchCommand = mockRun.mock.calls[0][0];
const expectedB64 = Buffer.from('echo hello && build').toString('base64');
expect(dispatchCommand).toContain(expectedB64);
});
it('includes environment variables as JSON input', async () => {
mockRun.mockResolvedValueOnce(''); // dispatch
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 200, status: 'completed' })); // run found
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' })); // status
mockRun.mockResolvedValueOnce('logs'); // logs
const env = [
{ name: 'UNITY_LICENSE', value: 'license-data' },
{ name: 'BUILD_TARGET', value: 'StandaloneWindows64' },
];
await provider.runTaskInWorkflow('guid-2', 'img', 'cmd', '/m', '/w', env as any, []);
const dispatchCommand = mockRun.mock.calls[0][0];
expect(dispatchCommand).toContain('UNITY_LICENSE');
expect(dispatchCommand).toContain('BUILD_TARGET');
});
it('throws when workflow dispatch fails', async () => {
mockRun.mockRejectedValueOnce(new Error('403 Forbidden'));
await expect(provider.runTaskInWorkflow('guid-err', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Failed to dispatch workflow',
);
});
it('throws when workflow run does not start within timeout', async () => {
mockRun.mockResolvedValueOnce(''); // dispatch succeeds
// All 30 poll attempts fail
for (let i = 0; i < 30; i++) {
mockRun.mockRejectedValueOnce(new Error('not found'));
}
await expect(provider.runTaskInWorkflow('guid-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Workflow run did not start within',
);
});
it('throws when workflow run fails with non-success conclusion', async () => {
mockRun.mockResolvedValueOnce(''); // dispatch
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 300, status: 'in_progress' })); // run appears
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'failure' })); // fails
await expect(provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Workflow run failed with conclusion: failure',
);
});
it('returns fallback message when log fetch fails', async () => {
mockRun.mockResolvedValueOnce(''); // dispatch
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 400, status: 'completed' })); // run appears
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' })); // completes
mockRun.mockRejectedValueOnce(new Error('logs unavailable')); // log fetch fails
const result = await provider.runTaskInWorkflow('guid-nologs', 'img', 'cmd', '/m', '/w', [], []);
expect(result).toContain('completed successfully');
expect(result).toContain('logs unavailable');
});
it('handles cancelled workflow run conclusion', async () => {
mockRun.mockResolvedValueOnce(''); // dispatch
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 500, status: 'in_progress' })); // run
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'cancelled' })); // cancelled
await expect(provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Workflow run failed with conclusion: cancelled',
);
});
it('throws timeout error when polling exceeds maximum duration', async () => {
// Save real Date.now
const realDateNow = Date.now;
let callCount = 0;
// dispatch succeeds
mockRun.mockResolvedValueOnce('');
// run appears
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 600, status: 'in_progress' }));
// Status always returns in_progress
mockRun.mockImplementation(() => Promise.resolve(JSON.stringify({ status: 'in_progress' })));
// First call returns normal time, subsequent calls simulate 5 hours elapsed
Date.now = () => {
callCount++;
if (callCount <= 2) return realDateNow.call(Date);
return realDateNow.call(Date) + 14_400_001; // 4 hours + 1ms
};
try {
await expect(provider.runTaskInWorkflow('guid-poll-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'did not complete within 4 hours',
);
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('did not complete within 4 hours'));
} finally {
Date.now = realDateNow;
}
});
});
describe('cleanupWorkflow', () => {
it('completes without error and logs cleanup message', async () => {
await provider.cleanupWorkflow(createBuildParameters(), 'main', []);
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleanup complete'));
});
});
describe('garbageCollect', () => {
it('returns empty string (no-op)', async () => {
const result = await provider.garbageCollect('', false, 0, false, false);
expect(result).toBe('');
});
});
describe('listResources', () => {
it('returns runner names from the repository', async () => {
mockRun.mockResolvedValueOnce('runner-1\nrunner-2\nrunner-3\n');
const resources = await provider.listResources();
expect(resources).toHaveLength(3);
expect(resources[0].Name).toBe('runner-1');
expect(resources[1].Name).toBe('runner-2');
expect(resources[2].Name).toBe('runner-3');
});
it('returns empty array when repo or token is missing', async () => {
const params = createBuildParameters({ githubActionsRepo: '' });
provider = new GitHubActionsProvider(params);
const resources = await provider.listResources();
expect(resources).toEqual([]);
expect(mockRun).not.toHaveBeenCalled();
});
it('returns empty array when API call fails', async () => {
mockRun.mockRejectedValueOnce(new Error('API error'));
const resources = await provider.listResources();
expect(resources).toEqual([]);
});
});
describe('listWorkflow', () => {
it('returns recent workflow run names', async () => {
mockRun.mockResolvedValueOnce('Build Unity\nRun Tests\n');
const workflows = await provider.listWorkflow();
expect(workflows).toHaveLength(2);
expect(workflows[0].Name).toBe('Build Unity');
expect(workflows[1].Name).toBe('Run Tests');
});
it('returns empty array when credentials missing', async () => {
const params = createBuildParameters({ githubActionsToken: '' });
provider = new GitHubActionsProvider(params);
const workflows = await provider.listWorkflow();
expect(workflows).toEqual([]);
});
});
describe('watchWorkflow', () => {
it('returns message when no active run exists', async () => {
const result = await provider.watchWorkflow();
expect(result).toBe('No active run to watch');
});
});
});

View File

@@ -1,4 +1,3 @@
import * as core from '@actions/core';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
@@ -8,8 +7,6 @@ import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
const MAX_POLLING_DURATION_MS = 14_400_000; // 4 hours
/**
* GitHub Actions provider — triggers builds as workflow_dispatch events
* on a target repository via the GitHub API.
@@ -134,20 +131,9 @@ class GitHubActionsProvider implements ProviderInterface {
throw new Error(`Workflow run did not start within ${maxAttempts * 10}s`);
}
// Poll until completion and stream logs (with maximum duration guard)
// Poll until completion and stream logs
let status = 'in_progress';
const pollingStartTime = Date.now();
const runUrl = `https://github.com/${this.repo}/actions/runs/${this.runId}`;
while (status === 'in_progress' || status === 'queued') {
const elapsedMs = Date.now() - pollingStartTime;
if (elapsedMs >= MAX_POLLING_DURATION_MS) {
const hours = Math.round(MAX_POLLING_DURATION_MS / 3_600_000);
const message = `GitHub Actions workflow did not complete within ${hours} hours. Run URL: ${runUrl}`;
core.error(message);
throw new Error(message);
}
await new Promise((resolve) => setTimeout(resolve, 15_000));
try {
@@ -174,9 +160,6 @@ class GitHubActionsProvider implements ProviderInterface {
if (error.message && error.message.includes('conclusion')) {
throw error;
}
if (error.message && error.message.includes('did not complete within')) {
throw error;
}
OrchestratorLogger.logWarning(`[GitHubActions] Status check error: ${error.message || error}`);
}
}

View File

@@ -1,329 +0,0 @@
import GitLabCIProvider from '.';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import * as core from '@actions/core';
jest.mock('../../services/core/orchestrator-system');
jest.mock('../../services/core/orchestrator-logger');
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(() => ''),
}));
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction<typeof OrchestratorLogger.logWarning>;
function createBuildParameters(overrides: Partial<BuildParameters> = {}): BuildParameters {
return {
gitlabProjectId: 'my-group/my-project',
gitlabTriggerToken: 'glptt-test-token-456',
gitlabApiUrl: 'https://gitlab.example.com',
gitlabRef: 'main',
...overrides,
} as BuildParameters;
}
// Override setTimeout to execute callbacks immediately so polling loops complete fast
const originalSetTimeout = global.setTimeout;
beforeAll(() => {
global.setTimeout = ((fn: (...args: any[]) => void, _ms?: number, ...args: any[]) => {
return originalSetTimeout(fn, 0, ...args);
}) as any;
});
afterAll(() => {
global.setTimeout = originalSetTimeout;
});
describe('GitLabCIProvider', () => {
let provider: GitLabCIProvider;
beforeEach(() => {
jest.clearAllMocks();
provider = new GitLabCIProvider(createBuildParameters());
});
describe('constructor', () => {
it('strips trailing slashes from apiUrl', () => {
const params = createBuildParameters({ gitlabApiUrl: 'https://gitlab.example.com///' });
const p = new GitLabCIProvider(params);
expect(p).toBeDefined();
});
it('defaults apiUrl to https://gitlab.com when not provided', () => {
const params = createBuildParameters({ gitlabApiUrl: undefined });
const p = new GitLabCIProvider(params);
expect(p).toBeDefined();
});
it('defaults ref to main when not provided', () => {
const params = createBuildParameters({ gitlabRef: undefined });
const p = new GitLabCIProvider(params);
expect(p).toBeDefined();
});
});
describe('setupWorkflow', () => {
it('verifies project access via curl and logs success', async () => {
mockRun.mockResolvedValueOnce('');
await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []);
expect(mockRun).toHaveBeenCalledTimes(1);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('curl -sf');
expect(command).toContain('PRIVATE-TOKEN: glptt-test-token-456');
expect(command).toContain('gitlab.example.com/api/v4/projects/');
expect(command).toContain(encodeURIComponent('my-group/my-project'));
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Project access verified'));
});
it('throws when projectId is not configured', async () => {
const params = createBuildParameters({ gitlabProjectId: '' });
provider = new GitLabCIProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'gitlabProjectId and gitlabTriggerToken are required',
);
});
it('throws when triggerToken is not configured', async () => {
const params = createBuildParameters({ gitlabTriggerToken: '' });
provider = new GitLabCIProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'gitlabProjectId and gitlabTriggerToken are required',
);
});
it('throws descriptive error when project access check fails', async () => {
mockRun.mockRejectedValueOnce(new Error('401 Unauthorized'));
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
'Failed to access GitLab project my-group/my-project',
);
});
});
describe('runTaskInWorkflow', () => {
it('triggers pipeline and returns job logs on success', async () => {
// Pipeline trigger response
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5001, status: 'pending' }));
// Status poll returns success
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
// Jobs list
mockRun.mockResolvedValueOnce(
JSON.stringify([
{ id: 10001, name: 'build-unity', status: 'success' },
{ id: 10002, name: 'test-unity', status: 'success' },
]),
);
// Job traces
mockRun.mockResolvedValueOnce('Building Unity project...\nDone.');
mockRun.mockResolvedValueOnce('Running tests...\nAll passed.');
const result = await provider.runTaskInWorkflow(
'guid-gl1',
'unityci/editor:2021.3',
'echo build',
'/mount',
'/work',
[],
[],
);
expect(result).toContain('build-unity');
expect(result).toContain('test-unity');
expect(result).toContain('Building Unity project');
expect(result).toContain('Running tests');
// Verify trigger command
const triggerCommand = mockRun.mock.calls[0][0];
expect(triggerCommand).toContain('trigger/pipeline');
expect(triggerCommand).toContain(`token=${createBuildParameters().gitlabTriggerToken}`);
expect(triggerCommand).toContain('ref=main');
});
it('passes build variables including base64-encoded commands', async () => {
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5002, status: 'success' }));
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
mockRun.mockResolvedValueOnce(JSON.stringify([]));
await provider.runTaskInWorkflow(
'guid-vars',
'ubuntu:20.04',
'make build',
'/mnt/data',
'/workspace',
[{ name: 'UNITY_VERSION', value: '2021.3.1f1' } as any],
[],
);
const triggerCommand = mockRun.mock.calls[0][0];
const expectedB64 = Buffer.from('make build').toString('base64');
expect(triggerCommand).toContain(`variables[BUILD_COMMANDS]=${expectedB64}`);
expect(triggerCommand).toContain('variables[BUILD_GUID]=guid-vars');
expect(triggerCommand).toContain('variables[BUILD_IMAGE]=ubuntu:20.04');
expect(triggerCommand).toContain('variables[MOUNT_DIR]=/mnt/data');
expect(triggerCommand).toContain('variables[WORKING_DIR]=/workspace');
expect(triggerCommand).toContain('variables[UNITY_VERSION]=2021.3.1f1');
});
it('throws when pipeline trigger fails', async () => {
mockRun.mockRejectedValueOnce(new Error('404 Not Found'));
await expect(provider.runTaskInWorkflow('guid-err', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Failed to trigger pipeline',
);
});
it('throws when pipeline finishes with failure status', async () => {
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5003, status: 'pending' }));
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'failed' }));
await expect(provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Pipeline 5003 finished with status: failed',
);
});
it('throws when pipeline is canceled', async () => {
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5004, status: 'pending' }));
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'canceled' }));
await expect(provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Pipeline 5004 finished with status: canceled',
);
});
it('handles job log fetch failures gracefully', async () => {
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5005, status: 'success' }));
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
mockRun.mockResolvedValueOnce(JSON.stringify([{ id: 20001, name: 'build', status: 'success' }]));
// Job trace fetch fails
mockRun.mockRejectedValueOnce(new Error('trace unavailable'));
const result = await provider.runTaskInWorkflow('guid-nologs', 'img', 'cmd', '/m', '/w', [], []);
expect(result).toContain('build');
expect(result).toContain('logs unavailable');
});
it('returns fallback message when entire job fetch fails', async () => {
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5006, status: 'success' }));
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
// Jobs list fails
mockRun.mockRejectedValueOnce(new Error('API error'));
const result = await provider.runTaskInWorkflow('guid-noapi', 'img', 'cmd', '/m', '/w', [], []);
expect(result).toContain('Pipeline 5006 completed successfully');
expect(result).toContain('logs unavailable');
});
it('continues polling through status check errors until completion', async () => {
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5007, status: 'pending' }));
// First status check fails
mockRun.mockRejectedValueOnce(new Error('network blip'));
// Second status check succeeds
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
// Jobs/logs
mockRun.mockResolvedValueOnce(JSON.stringify([]));
await provider.runTaskInWorkflow('guid-retry', 'img', 'cmd', '/m', '/w', [], []);
expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Status check error'));
});
it('throws timeout error when polling exceeds maximum duration', async () => {
const realDateNow = Date.now;
let callCount = 0;
// Trigger pipeline succeeds
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5008, status: 'running' }));
// Status always returns running
mockRun.mockImplementation(() => Promise.resolve(JSON.stringify({ status: 'running' })));
// After first call, simulate 5 hours elapsed
Date.now = () => {
callCount++;
if (callCount <= 1) return realDateNow.call(Date);
return realDateNow.call(Date) + 14_400_001; // 4 hours + 1ms
};
try {
await expect(provider.runTaskInWorkflow('guid-poll-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'did not complete within 4 hours',
);
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('did not complete within 4 hours'));
} finally {
Date.now = realDateNow;
}
});
});
describe('cleanupWorkflow', () => {
it('completes without error and logs cleanup message', async () => {
await provider.cleanupWorkflow(createBuildParameters(), 'main', []);
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleanup complete'));
});
});
describe('garbageCollect', () => {
it('returns empty string (no-op)', async () => {
const result = await provider.garbageCollect('', false, 0, false, false);
expect(result).toBe('');
});
});
describe('listResources', () => {
it('returns empty array (not implemented)', async () => {
const resources = await provider.listResources();
expect(resources).toEqual([]);
});
});
describe('listWorkflow', () => {
it('returns recent pipeline names when credentials are available', async () => {
mockRun.mockResolvedValueOnce(
JSON.stringify([
{ id: 100, status: 'success' },
{ id: 101, status: 'failed' },
]),
);
const workflows = await provider.listWorkflow();
expect(workflows).toHaveLength(2);
expect(workflows[0].Name).toBe('Pipeline #100 (success)');
expect(workflows[1].Name).toBe('Pipeline #101 (failed)');
});
it('returns empty array when credentials are missing', async () => {
const params = createBuildParameters({ gitlabProjectId: '' });
provider = new GitLabCIProvider(params);
const workflows = await provider.listWorkflow();
expect(workflows).toEqual([]);
expect(mockRun).not.toHaveBeenCalled();
});
it('returns empty array when API call fails', async () => {
mockRun.mockRejectedValueOnce(new Error('API error'));
const workflows = await provider.listWorkflow();
expect(workflows).toEqual([]);
});
});
describe('watchWorkflow', () => {
it('returns empty string (not implemented)', async () => {
const result = await provider.watchWorkflow();
expect(result).toBe('');
});
});
});

View File

@@ -1,4 +1,3 @@
import * as core from '@actions/core';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
@@ -8,8 +7,6 @@ import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
const MAX_POLLING_DURATION_MS = 14_400_000; // 4 hours
/**
* GitLab CI provider — triggers builds as GitLab CI pipelines
* via the GitLab API.
@@ -103,21 +100,11 @@ class GitLabCIProvider implements ProviderInterface {
throw new Error(`Failed to trigger pipeline: ${error.message || error}`);
}
// Poll until completion (with maximum duration guard)
// Poll until completion
let status = 'pending';
const terminalStatuses = new Set(['success', 'failed', 'canceled', 'skipped']);
const pollingStartTime = Date.now();
const pipelineUrl = `${this.apiUrl}/${this.projectId}/-/pipelines/${this.pipelineId}`;
while (!terminalStatuses.has(status)) {
const elapsedMs = Date.now() - pollingStartTime;
if (elapsedMs >= MAX_POLLING_DURATION_MS) {
const hours = Math.round(MAX_POLLING_DURATION_MS / 3_600_000);
const message = `GitLab CI pipeline did not complete within ${hours} hours. Pipeline URL: ${pipelineUrl}`;
core.error(message);
throw new Error(message);
}
await new Promise((resolve) => setTimeout(resolve, 15_000));
try {

View File

@@ -58,13 +58,10 @@ export default async function loadProvider(
const providerModuleMap: Record<string, string> = {
aws: './aws',
k8s: './k8s',
cli: './cli',
test: './test',
'local-docker': './docker',
'local-system': './local',
local: './local',
'gcp-cloud-run': './gcp-cloud-run',
'azure-aci': './azure-aci',
};
modulePath = providerModuleMap[providerSource] || providerSource;
@@ -139,7 +136,7 @@ export class ProviderLoader {
* @returns string[] - Array of available provider names
*/
static getAvailableProviders(): string[] {
return ['aws', 'k8s', 'cli', 'test', 'local-docker', 'local-system', 'local', 'gcp-cloud-run', 'azure-aci'];
return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local'];
}
/**

View File

@@ -1,164 +0,0 @@
import BuildParameters from '../../build-parameters';
import RemotePowershellProvider from './remote-powershell';
import GitHubActionsProvider from './github-actions';
import GitLabCIProvider from './gitlab-ci';
import AnsibleProvider from './ansible';
/**
* Tests for provider selection logic in Orchestrator.setProvider.
*
* These tests verify that the correct provider class is instantiated based on
* the providerStrategy field in BuildParameters. Rather than invoking the full
* Orchestrator.setProvider (which has heavy dependencies on OrchestratorOptions,
* AWS detection, etc.), we test the provider constructors directly to verify
* they produce the right provider type from the same build parameters the
* orchestrator switch statement uses.
*/
describe('Provider Selection', () => {
describe('remote-powershell provider', () => {
it('creates RemotePowershellProvider from build parameters', () => {
const params = {
providerStrategy: 'remote-powershell',
remotePowershellHost: 'build-server.local',
remotePowershellTransport: 'wsman',
remotePowershellCredential: 'user:pass',
} as BuildParameters;
const provider = new RemotePowershellProvider(params);
expect(provider).toBeInstanceOf(RemotePowershellProvider);
expect(provider.constructor.name).toBe('RemotePowershellProvider');
});
});
describe('github-actions provider', () => {
it('creates GitHubActionsProvider from build parameters', () => {
const params = {
providerStrategy: 'github-actions',
githubActionsRepo: 'org/repo',
githubActionsWorkflow: 'ci.yml',
githubActionsToken: 'ghp_token',
githubActionsRef: 'main',
} as BuildParameters;
const provider = new GitHubActionsProvider(params);
expect(provider).toBeInstanceOf(GitHubActionsProvider);
expect(provider.constructor.name).toBe('GitHubActionsProvider');
});
});
describe('gitlab-ci provider', () => {
it('creates GitLabCIProvider from build parameters', () => {
const params = {
providerStrategy: 'gitlab-ci',
gitlabProjectId: 'group/project',
gitlabTriggerToken: 'glptt-token',
gitlabApiUrl: 'https://gitlab.com',
gitlabRef: 'main',
} as BuildParameters;
const provider = new GitLabCIProvider(params);
expect(provider).toBeInstanceOf(GitLabCIProvider);
expect(provider.constructor.name).toBe('GitLabCIProvider');
});
});
describe('ansible provider', () => {
it('creates AnsibleProvider from build parameters', () => {
const params = {
providerStrategy: 'ansible',
ansibleInventory: '/etc/ansible/hosts',
ansiblePlaybook: '/playbooks/build.yml',
ansibleExtraVars: '',
ansibleVaultPassword: '',
} as BuildParameters;
const provider = new AnsibleProvider(params);
expect(provider).toBeInstanceOf(AnsibleProvider);
expect(provider.constructor.name).toBe('AnsibleProvider');
});
});
describe('provider strategy routing', () => {
it('each provider strategy maps to a distinct provider class', () => {
const strategies: Record<string, new (params: BuildParameters) => any> = {
'remote-powershell': RemotePowershellProvider,
'github-actions': GitHubActionsProvider,
'gitlab-ci': GitLabCIProvider,
ansible: AnsibleProvider,
};
const params = {
remotePowershellHost: 'host',
remotePowershellTransport: 'wsman',
remotePowershellCredential: '',
githubActionsRepo: 'org/repo',
githubActionsWorkflow: 'ci.yml',
githubActionsToken: 'token',
githubActionsRef: 'main',
gitlabProjectId: 'proj',
gitlabTriggerToken: 'tok',
gitlabApiUrl: 'https://gitlab.com',
gitlabRef: 'main',
ansibleInventory: '/inv',
ansiblePlaybook: '/pb.yml',
ansibleExtraVars: '',
ansibleVaultPassword: '',
} as BuildParameters;
const instances = Object.entries(strategies).map(([strategy, ProviderClass]) => {
const provider = new ProviderClass(params);
return { strategy, className: provider.constructor.name };
});
// Verify all four strategies produce different provider classes
const classNames = instances.map((i) => i.className);
const uniqueClassNames = new Set(classNames);
expect(uniqueClassNames.size).toBe(4);
// Verify expected mapping
expect(instances.find((i) => i.strategy === 'remote-powershell')!.className).toBe('RemotePowershellProvider');
expect(instances.find((i) => i.strategy === 'github-actions')!.className).toBe('GitHubActionsProvider');
expect(instances.find((i) => i.strategy === 'gitlab-ci')!.className).toBe('GitLabCIProvider');
expect(instances.find((i) => i.strategy === 'ansible')!.className).toBe('AnsibleProvider');
});
it('all providers implement ProviderInterface methods', () => {
const params = {
remotePowershellHost: 'host',
githubActionsRepo: 'org/repo',
githubActionsWorkflow: 'ci.yml',
githubActionsToken: 'token',
gitlabProjectId: 'proj',
gitlabTriggerToken: 'tok',
ansibleInventory: '/inv',
} as BuildParameters;
const providers = [
new RemotePowershellProvider(params),
new GitHubActionsProvider(params),
new GitLabCIProvider(params),
new AnsibleProvider(params),
];
const requiredMethods = [
'setupWorkflow',
'runTaskInWorkflow',
'cleanupWorkflow',
'garbageCollect',
'listResources',
'listWorkflow',
'watchWorkflow',
];
for (const provider of providers) {
for (const method of requiredMethods) {
expect(typeof (provider as any)[method]).toBe('function');
}
}
});
});
});

View File

@@ -148,17 +148,11 @@ class RemotePowershellProvider implements ProviderInterface {
}
// WinRM (default)
// Split on the FIRST colon only — passwords may contain colons
let credentialPart = '';
if (this.credential) {
const colonIndex = this.credential.indexOf(':');
if (colonIndex === -1) {
throw new Error('remotePowershellCredential must be in "username:password" format (no colon found)');
}
const user = this.credential.substring(0, colonIndex);
const pass = this.credential.substring(colonIndex + 1);
credentialPart = `-Credential (New-Object PSCredential('${user}', (ConvertTo-SecureString '${pass}' -AsPlainText -Force)))`;
}
const credentialPart = this.credential
? `-Credential (New-Object PSCredential('${this.credential.split(':')[0]}', (ConvertTo-SecureString '${
this.credential.split(':')[1]
}' -AsPlainText -Force)))`
: '';
return `pwsh -NoProfile -NonInteractive -Command "Invoke-Command -ComputerName '${this.host}' ${credentialPart} -ScriptBlock { ${escapedScript} }"`;
}

View File

@@ -1,264 +0,0 @@
import RemotePowershellProvider from '.';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
jest.mock('../../services/core/orchestrator-system');
jest.mock('../../services/core/orchestrator-logger');
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction<typeof OrchestratorLogger.logWarning>;
function createBuildParameters(overrides: Partial<BuildParameters> = {}): BuildParameters {
return {
remotePowershellHost: 'build-server-01.internal',
remotePowershellTransport: 'wsman',
remotePowershellCredential: 'admin:P@ssw0rd!',
...overrides,
} as BuildParameters;
}
describe('RemotePowershellProvider', () => {
let provider: RemotePowershellProvider;
beforeEach(() => {
jest.clearAllMocks();
provider = new RemotePowershellProvider(createBuildParameters());
});
describe('constructor', () => {
it('defaults transport to wsman when not specified', () => {
const params = createBuildParameters({ remotePowershellTransport: undefined });
const p = new RemotePowershellProvider(params);
expect(p).toBeDefined();
});
it('accepts ssh transport', () => {
const params = createBuildParameters({ remotePowershellTransport: 'ssh' });
const p = new RemotePowershellProvider(params);
expect(p).toBeDefined();
});
});
describe('setupWorkflow', () => {
it('tests WinRM connectivity via Test-WSMan and logs success', async () => {
mockRun.mockResolvedValueOnce('wsman output');
await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []);
expect(mockRun).toHaveBeenCalledTimes(1);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('pwsh -NoProfile -NonInteractive');
expect(command).toContain('Test-WSMan');
expect(command).toContain('build-server-01.internal');
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Connection test passed'));
});
it('sets session ID to the build GUID', async () => {
mockRun.mockResolvedValueOnce('');
await provider.setupWorkflow('my-build-guid', createBuildParameters(), 'main', []);
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('my-build-guid'));
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ready'));
});
it('throws when host is not configured', async () => {
const params = createBuildParameters({ remotePowershellHost: '' });
provider = new RemotePowershellProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'remotePowershellHost is required',
);
});
it('throws descriptive error when connectivity test fails', async () => {
mockRun.mockRejectedValueOnce(new Error('WinRM service not running'));
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
'Failed to connect to remote host build-server-01.internal',
);
});
});
describe('runTaskInWorkflow', () => {
it('constructs WinRM Invoke-Command with credential and returns output', async () => {
mockRun.mockResolvedValueOnce('Build succeeded!');
const result = await provider.runTaskInWorkflow(
'guid-run1',
'unused-image',
'Unity.exe -batchmode -buildTarget Win64',
'/mount',
'C:\\Projects\\MyGame',
[],
[],
);
expect(result).toBe('Build succeeded!');
const command = mockRun.mock.calls[0][0];
expect(command).toContain('pwsh -NoProfile -NonInteractive');
expect(command).toContain("Invoke-Command -ComputerName 'build-server-01.internal'");
expect(command).toContain('-Credential');
expect(command).toContain('New-Object PSCredential');
expect(command).toContain('-ScriptBlock');
expect(command).toContain('Set-Location');
});
it('constructs SSH Invoke-Command when transport is ssh', async () => {
const params = createBuildParameters({ remotePowershellTransport: 'ssh' });
provider = new RemotePowershellProvider(params);
mockRun.mockResolvedValueOnce('SSH build output');
const result = await provider.runTaskInWorkflow('guid-ssh', 'img', 'build', '/m', '/w', [], []);
expect(result).toBe('SSH build output');
const command = mockRun.mock.calls[0][0];
expect(command).toContain("Invoke-Command -HostName 'build-server-01.internal'");
expect(command).not.toContain('-ComputerName');
expect(command).not.toContain('-Credential');
});
it('includes environment variables in the remote script block', async () => {
mockRun.mockResolvedValueOnce('output');
const env = [
{ name: 'UNITY_LICENSE', value: 'license-data-abc' },
{ name: 'BUILD_TARGET', value: 'StandaloneWindows64' },
];
await provider.runTaskInWorkflow('guid-env', 'img', 'build-cmd', '/m', '/w', env as any, []);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('$env:UNITY_LICENSE');
expect(command).toContain('$env:BUILD_TARGET');
});
it('includes secrets in the remote script block', async () => {
mockRun.mockResolvedValueOnce('output');
const secrets = [{ ParameterKey: 'key1', EnvironmentVariable: 'SECRET_KEY', ParameterValue: 'secret-val-123' }];
await provider.runTaskInWorkflow('guid-sec', 'img', 'build-cmd', '/m', '/w', [], secrets as any);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('$env:SECRET_KEY');
});
it('does not include credential in plaintext log output when using WinRM', async () => {
mockRun.mockResolvedValueOnce('output');
await provider.runTaskInWorkflow('guid-cred', 'img', 'cmd', '/m', '/w', [], []);
// The credential is used via ConvertTo-SecureString, not logged directly
const command = mockRun.mock.calls[0][0];
expect(command).toContain('ConvertTo-SecureString');
expect(command).toContain('-AsPlainText -Force');
});
it('omits credential part when no credential is configured (WinRM)', async () => {
const params = createBuildParameters({ remotePowershellCredential: '' });
provider = new RemotePowershellProvider(params);
mockRun.mockResolvedValueOnce('output');
await provider.runTaskInWorkflow('guid-nocred', 'img', 'cmd', '/m', '/w', [], []);
const command = mockRun.mock.calls[0][0];
expect(command).toContain("Invoke-Command -ComputerName 'build-server-01.internal'");
expect(command).not.toContain('-Credential');
expect(command).not.toContain('PSCredential');
});
it('throws and logs warning when remote execution fails', async () => {
const execError = new Error('Remote execution failed: access denied');
mockRun.mockRejectedValueOnce(execError);
await expect(provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Remote execution failed',
);
expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Task failed'));
});
it('preserves passwords containing colons when splitting credentials', async () => {
const params = createBuildParameters({
remotePowershellCredential: 'admin:P@ss:w0rd:with:colons!',
});
provider = new RemotePowershellProvider(params);
mockRun.mockResolvedValueOnce('output');
await provider.runTaskInWorkflow('guid-colon', 'img', 'cmd', '/m', '/w', [], []);
const command = mockRun.mock.calls[0][0];
expect(command).toContain("PSCredential('admin'");
expect(command).toContain("ConvertTo-SecureString 'P@ss:w0rd:with:colons!'");
});
it('throws when credential has no colon separator', async () => {
const params = createBuildParameters({
remotePowershellCredential: 'nocolonhere',
});
provider = new RemotePowershellProvider(params);
await expect(provider.runTaskInWorkflow('guid-badcred', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'username:password',
);
});
it('sets working directory in the remote script', async () => {
mockRun.mockResolvedValueOnce('output');
await provider.runTaskInWorkflow('guid-wd', 'img', 'cmd', '/m', 'D:\\Builds\\Project', [], []);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('Set-Location');
expect(command).toContain('D:\\Builds\\Project');
});
});
describe('cleanupWorkflow', () => {
it('completes without error and logs session cleanup', async () => {
// Setup first to set sessionId
mockRun.mockResolvedValueOnce('');
await provider.setupWorkflow('guid-cleanup', createBuildParameters(), 'main', []);
await provider.cleanupWorkflow(createBuildParameters(), 'main', []);
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleaning up session'));
});
});
describe('garbageCollect', () => {
it('returns empty string and logs not-supported message', async () => {
const result = await provider.garbageCollect('', false, 0, false, false);
expect(result).toBe('');
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('not supported'));
});
});
describe('listResources', () => {
it('returns the configured host as a resource', async () => {
const resources = await provider.listResources();
expect(resources).toHaveLength(1);
expect(resources[0].Name).toBe('build-server-01.internal');
});
});
describe('listWorkflow', () => {
it('returns empty array (not implemented)', async () => {
const workflows = await provider.listWorkflow();
expect(workflows).toEqual([]);
});
});
describe('watchWorkflow', () => {
it('returns empty string (not implemented)', async () => {
const result = await provider.watchWorkflow();
expect(result).toBe('');
});
});
});

View File

@@ -332,23 +332,6 @@ export class RemoteClient {
`mkdir -p ${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.cacheFolderForCacheKeyFull)}`,
);
await RemoteClient.cloneRepoWithoutLFSFiles();
// Initialize submodules from profile if configured
if (Orchestrator.buildParameters.submoduleProfilePath) {
const { SubmoduleProfileService } = await import('../services/submodule/submodule-profile-service');
RemoteClientLogger.log('Initializing submodules from profile...');
const plan = await SubmoduleProfileService.createInitPlan(
Orchestrator.buildParameters.submoduleProfilePath,
Orchestrator.buildParameters.submoduleVariantPath,
OrchestratorFolders.repoPathAbsolute,
);
await SubmoduleProfileService.execute(
plan,
OrchestratorFolders.repoPathAbsolute,
Orchestrator.buildParameters.submoduleToken || Orchestrator.buildParameters.gitPrivateToken,
);
}
await RemoteClient.sizeOfFolder(
'repo before lfs cache pull',
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.repoPathAbsolute),
@@ -363,19 +346,6 @@ export class RemoteClient {
`${lfsHashes.lfsGuidSum}`,
);
await RemoteClient.sizeOfFolder('repo after lfs cache pull', OrchestratorFolders.repoPathAbsolute);
// Configure custom LFS transfer agent if specified
if (Orchestrator.buildParameters.lfsTransferAgent) {
const { LfsAgentService } = await import('../services/lfs/lfs-agent-service');
RemoteClientLogger.log('Configuring custom LFS transfer agent...');
await LfsAgentService.configure(
Orchestrator.buildParameters.lfsTransferAgent,
Orchestrator.buildParameters.lfsTransferAgentArgs,
Orchestrator.buildParameters.lfsStoragePaths ? Orchestrator.buildParameters.lfsStoragePaths.split(';') : [],
OrchestratorFolders.repoPathAbsolute,
);
}
await RemoteClient.pullLatestLFS();
await RemoteClient.sizeOfFolder('repo before lfs git pull', OrchestratorFolders.repoPathAbsolute);
await Caching.PushToCache(
@@ -427,7 +397,6 @@ export class RemoteClient {
RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
await OrchestratorSystem.Run(`git config --global advice.detachedHead false`);
await OrchestratorFolders.configureGitAuth();
RemoteClientLogger.log(`Cloning the repository being built:`);
await OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
await OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
@@ -537,7 +506,12 @@ export class RemoteClient {
const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN;
if (gitPrivateToken) {
RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`);
await RemoteClient.configureTokenAuth(gitPrivateToken);
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
await OrchestratorSystem.Run(
`git config --global url."https://${gitPrivateToken}@github.com/".insteadOf "https://github.com/"`,
);
await OrchestratorSystem.Run(`git lfs pull`, true);
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
@@ -553,7 +527,12 @@ export class RemoteClient {
const githubToken = process.env.GITHUB_TOKEN;
if (githubToken) {
RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`);
await RemoteClient.configureTokenAuth(githubToken);
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
await OrchestratorSystem.Run(
`git config --global url."https://${githubToken}@github.com/".insteadOf "https://github.com/"`,
);
await OrchestratorSystem.Run(`git lfs pull`, true);
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
@@ -617,25 +596,4 @@ export class RemoteClient {
return false;
}
/**
* Configure git authentication for a token. In header mode (default), uses
* http.extraHeader so the token never appears in URLs or git config output.
* In url mode (legacy), uses url.insteadOf to embed the token in URLs.
*/
private static async configureTokenAuth(token: string): Promise<void> {
if (OrchestratorFolders.useHeaderAuth) {
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
await OrchestratorSystem.Run(
`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`,
);
} else {
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
await OrchestratorSystem.Run(
`git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"`,
);
}
}
}

View File

@@ -1,458 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { ChildWorkspaceService, ChildWorkspaceConfig } from './child-workspace-service';
jest.mock('node:fs');
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
error: jest.fn(),
},
}));
const mockFs = fs as jest.Mocked<typeof fs>;
function createConfig(overrides: Partial<ChildWorkspaceConfig> = {}): ChildWorkspaceConfig {
return {
enabled: true,
workspaceName: 'TurnOfWar',
parentCacheRoot: '/cache/workspaces',
preserveGitDirectory: true,
separateLibraryCache: true,
...overrides,
};
}
describe('ChildWorkspaceService', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('initializeWorkspace', () => {
it('should return false when no cached workspace exists', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = ChildWorkspaceService.initializeWorkspace('/project', createConfig());
expect(result).toBe(false);
});
it('should return false when cached workspace is empty', () => {
(mockFs.existsSync as jest.Mock).mockImplementation(
(p: string) => String(p) === path.join('/cache/workspaces', 'TurnOfWar'),
);
(mockFs.readdirSync as jest.Mock).mockReturnValue([]);
const result = ChildWorkspaceService.initializeWorkspace('/project', createConfig());
expect(result).toBe(false);
expect(mockFs.rmSync).toHaveBeenCalledWith(path.join('/cache/workspaces', 'TurnOfWar'), {
recursive: true,
force: true,
});
});
it('should restore workspace via atomic move when cache exists', () => {
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === cachedPath) return true;
if (String(p) === '/project') return false;
if (String(p) === '/') return true;
return false;
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['Assets', '.git', 'Library']);
const config = createConfig({ separateLibraryCache: false });
const result = ChildWorkspaceService.initializeWorkspace('/project', config);
expect(result).toBe(true);
expect(mockFs.renameSync).toHaveBeenCalledWith(cachedPath, '/project');
});
it('should remove existing target path before restoring', () => {
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === cachedPath) return true;
if (String(p) === '/project') return true;
return false;
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['Assets']);
const config = createConfig({ separateLibraryCache: false });
const result = ChildWorkspaceService.initializeWorkspace('/project', config);
expect(result).toBe(true);
expect(mockFs.rmSync).toHaveBeenCalledWith('/project', { recursive: true, force: true });
});
it('should restore Library cache separately when configured', () => {
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
const libraryBackupPath = path.join('/cache/workspaces', 'TurnOfWar-Library');
const libraryDestination = path.join('/project', 'Library');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === cachedPath) return true;
if (String(p) === '/project') return false;
if (String(p) === libraryBackupPath) return true;
if (String(p) === libraryDestination) return false;
return true; // parent dirs
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['Assets', 'ProjectSettings']);
const result = ChildWorkspaceService.initializeWorkspace('/project', createConfig());
expect(result).toBe(true);
// Should have been called twice: once for workspace, once for Library
expect(mockFs.renameSync).toHaveBeenCalledTimes(2);
expect(mockFs.renameSync).toHaveBeenCalledWith(cachedPath, '/project');
expect(mockFs.renameSync).toHaveBeenCalledWith(libraryBackupPath, libraryDestination);
});
it('should return false and log warning on error', () => {
(mockFs.existsSync as jest.Mock).mockImplementation(() => {
throw new Error('Access denied');
});
const result = ChildWorkspaceService.initializeWorkspace('/project', createConfig());
expect(result).toBe(false);
});
});
describe('saveWorkspace', () => {
it('should skip save when project path does not exist', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
ChildWorkspaceService.saveWorkspace('/project', createConfig());
expect(mockFs.renameSync).not.toHaveBeenCalled();
});
it('should save workspace via atomic move', () => {
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === '/project') return true;
if (String(p) === path.join('/project', 'Library')) return false;
if (String(p) === '/cache/workspaces') return true;
if (String(p) === cachedPath) return false;
return false;
});
const config = createConfig({ separateLibraryCache: false });
ChildWorkspaceService.saveWorkspace('/project', config);
expect(mockFs.renameSync).toHaveBeenCalledWith('/project', cachedPath);
});
it('should remove .git directory when preserveGit is false', () => {
const gitDirectory = path.join('/project', '.git');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === '/project') return true;
if (String(p) === gitDirectory) return true;
if (String(p) === path.join('/project', 'Library')) return false;
if (String(p) === '/cache/workspaces') return true;
return false;
});
const config = createConfig({ preserveGitDirectory: false, separateLibraryCache: false });
ChildWorkspaceService.saveWorkspace('/project', config);
expect(mockFs.rmSync).toHaveBeenCalledWith(gitDirectory, { recursive: true, force: true });
});
it('should not remove .git directory when preserveGit is true', () => {
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === '/project') return true;
if (String(p) === path.join('/project', 'Library')) return false;
if (String(p) === '/cache/workspaces') return true;
return false;
});
const config = createConfig({ preserveGitDirectory: true, separateLibraryCache: false });
ChildWorkspaceService.saveWorkspace('/project', config);
// rmSync should not have been called with .git path
const rmSyncCalls = (mockFs.rmSync as jest.Mock).mock.calls;
const gitRmCalls = rmSyncCalls.filter((call: any[]) => String(call[0]).includes('.git'));
expect(gitRmCalls).toHaveLength(0);
});
it('should remove existing cached workspace before saving', () => {
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === '/project') return true;
if (String(p) === path.join('/project', 'Library')) return false;
if (String(p) === '/cache/workspaces') return true;
if (String(p) === cachedPath) return true;
return false;
});
const config = createConfig({ separateLibraryCache: false });
ChildWorkspaceService.saveWorkspace('/project', config);
expect(mockFs.rmSync).toHaveBeenCalledWith(cachedPath, { recursive: true, force: true });
expect(mockFs.renameSync).toHaveBeenCalledWith('/project', cachedPath);
});
it('should save Library separately when separateLibraryCache is true', () => {
const libraryPath = path.join('/project', 'Library');
const libraryBackupPath = path.join('/cache/workspaces', 'TurnOfWar-Library');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === '/project') return true;
if (String(p) === libraryPath) return true;
if (String(p) === libraryBackupPath) return false;
if (String(p) === '/cache/workspaces') return true;
return false;
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['ScriptAssemblies', 'ShaderCache']);
ChildWorkspaceService.saveWorkspace('/project', createConfig());
expect(mockFs.renameSync).toHaveBeenCalledWith(libraryPath, libraryBackupPath);
});
it('should handle save errors gracefully', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.renameSync as jest.Mock).mockImplementation(() => {
throw new Error('Cross-device link');
});
(mockFs.readdirSync as jest.Mock).mockReturnValue([]);
// Should not throw
ChildWorkspaceService.saveWorkspace('/project', createConfig({ separateLibraryCache: false }));
});
});
describe('restoreLibraryCache', () => {
it('should return false when no Library backup exists', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = ChildWorkspaceService.restoreLibraryCache('/project', createConfig());
expect(result).toBe(false);
});
it('should return false when Library backup is empty', () => {
const libraryBackup = path.join('/cache/workspaces', 'TurnOfWar-Library');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => String(p) === libraryBackup);
(mockFs.readdirSync as jest.Mock).mockReturnValue([]);
const result = ChildWorkspaceService.restoreLibraryCache('/project', createConfig());
expect(result).toBe(false);
expect(mockFs.rmSync).toHaveBeenCalledWith(libraryBackup, { recursive: true, force: true });
});
it('should restore Library via atomic move', () => {
const libraryBackup = path.join('/cache/workspaces', 'TurnOfWar-Library');
const libraryDestination = path.join('/project', 'Library');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === libraryBackup) return true;
if (String(p) === libraryDestination) return false;
return false;
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['ScriptAssemblies']);
const result = ChildWorkspaceService.restoreLibraryCache('/project', createConfig());
expect(result).toBe(true);
expect(mockFs.renameSync).toHaveBeenCalledWith(libraryBackup, libraryDestination);
});
it('should use custom libraryBackupPath when provided', () => {
const customBackup = '/custom/library/cache';
const libraryDestination = path.join('/project', 'Library');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === customBackup) return true;
if (String(p) === libraryDestination) return false;
return false;
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['ScriptAssemblies']);
const config = createConfig({ libraryBackupPath: customBackup });
const result = ChildWorkspaceService.restoreLibraryCache('/project', config);
expect(result).toBe(true);
expect(mockFs.renameSync).toHaveBeenCalledWith(customBackup, libraryDestination);
});
it('should remove existing Library directory before restore', () => {
const libraryBackup = path.join('/cache/workspaces', 'TurnOfWar-Library');
const libraryDestination = path.join('/project', 'Library');
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
if (String(p) === libraryBackup) return true;
if (String(p) === libraryDestination) return true;
return false;
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['ScriptAssemblies']);
ChildWorkspaceService.restoreLibraryCache('/project', createConfig());
expect(mockFs.rmSync).toHaveBeenCalledWith(libraryDestination, { recursive: true, force: true });
});
});
describe('getWorkspaceSize', () => {
it('should return "0 B" for non-existent directory', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = ChildWorkspaceService.getWorkspaceSize('/nonexistent');
expect(result).toBe('0 B');
});
it('should calculate and format directory size', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue([
{ name: 'file1.txt', isDirectory: () => false, isFile: () => true },
{ name: 'file2.bin', isDirectory: () => false, isFile: () => true },
]);
(mockFs.statSync as jest.Mock).mockReturnValue({ size: 1024 * 1024 }); // 1 MB each
const result = ChildWorkspaceService.getWorkspaceSize('/workspace');
expect(result).toBe('2.00 MB');
});
it('should return "unknown" when existsSync throws', () => {
(mockFs.existsSync as jest.Mock).mockImplementation(() => {
throw new Error('Permission denied');
});
const result = ChildWorkspaceService.getWorkspaceSize('/workspace');
expect(result).toBe('unknown');
});
it('should recurse into subdirectories', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
let callCount = 0;
(mockFs.readdirSync as jest.Mock).mockImplementation(() => {
callCount++;
if (callCount === 1) {
return [
{ name: 'subdir', isDirectory: () => true, isFile: () => false },
{ name: 'root.txt', isDirectory: () => false, isFile: () => true },
];
}
return [{ name: 'nested.txt', isDirectory: () => false, isFile: () => true }];
});
(mockFs.statSync as jest.Mock).mockReturnValue({ size: 512 });
const result = ChildWorkspaceService.getWorkspaceSize('/workspace');
expect(result).toBe('1.00 KB');
});
});
describe('cleanStaleWorkspaces', () => {
it('should skip when cache root does not exist', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
ChildWorkspaceService.cleanStaleWorkspaces('/nonexistent', 7);
expect(mockFs.readdirSync).not.toHaveBeenCalled();
});
it('should remove workspaces older than retention period', () => {
const now = Date.now();
const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000;
const oneDayAgo = now - 1 * 24 * 60 * 60 * 1000;
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockImplementation((directoryPath: string) => {
if (String(directoryPath) === '/cache') {
return ['old-workspace', 'recent-workspace'];
}
return [];
});
(mockFs.statSync as jest.Mock).mockImplementation((filePath: string) => ({
isDirectory: () => true,
mtimeMs: String(filePath).includes('old') ? tenDaysAgo : oneDayAgo,
size: 0,
}));
ChildWorkspaceService.cleanStaleWorkspaces('/cache', 7);
expect(mockFs.rmSync).toHaveBeenCalledTimes(1);
expect(mockFs.rmSync).toHaveBeenCalledWith(path.join('/cache', 'old-workspace'), {
recursive: true,
force: true,
});
});
it('should not remove workspaces newer than retention period', () => {
const oneDayAgo = Date.now() - 1 * 24 * 60 * 60 * 1000;
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['recent-workspace']);
(mockFs.statSync as jest.Mock).mockReturnValue({
isDirectory: () => true,
mtimeMs: oneDayAgo,
});
ChildWorkspaceService.cleanStaleWorkspaces('/cache', 7);
expect(mockFs.rmSync).not.toHaveBeenCalled();
});
it('should handle errors during cleanup gracefully', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['broken-workspace']);
(mockFs.statSync as jest.Mock).mockImplementation(() => {
throw new Error('Access denied');
});
// Should not throw
ChildWorkspaceService.cleanStaleWorkspaces('/cache', 7);
});
});
describe('buildConfig', () => {
it('should build config from build parameters', () => {
const config = ChildWorkspaceService.buildConfig({
childWorkspacesEnabled: true,
childWorkspaceName: 'Shell',
childWorkspaceCacheRoot: '/d/cache',
childWorkspacePreserveGit: false,
childWorkspaceSeparateLibrary: true,
});
expect(config).toEqual({
enabled: true,
workspaceName: 'Shell',
parentCacheRoot: '/d/cache',
preserveGitDirectory: false,
separateLibraryCache: true,
});
});
it('should build config with defaults from disabled state', () => {
const config = ChildWorkspaceService.buildConfig({
childWorkspacesEnabled: false,
childWorkspaceName: '',
childWorkspaceCacheRoot: '',
childWorkspacePreserveGit: true,
childWorkspaceSeparateLibrary: true,
});
expect(config.enabled).toBe(false);
});
});
});

View File

@@ -1,373 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import OrchestratorLogger from '../core/orchestrator-logger';
export interface ChildWorkspaceConfig {
enabled: boolean;
/** Name for this child workspace, used as cache key (e.g., "TurnOfWar", "Shell") */
workspaceName: string;
/** Parent directory for cached child workspaces. Should be on same NTFS volume for O(1) restore. */
parentCacheRoot: string;
/** Keep .git directory in cached workspace for delta operations (default: true) */
preserveGitDirectory: boolean;
/** Cache Library/ independently from workspace (default: true) */
separateLibraryCache: boolean;
/** Override location for Library cache. Defaults to parentCacheRoot/<workspaceName>/Library-cache */
libraryBackupPath?: string;
}
/**
* Child workspace isolation service for enterprise-scale CI builds.
*
* Instead of building in the git checkout directory, this service:
* 1. Keeps the root workspace lean (no LFS files in the checkout dir)
* 2. Creates isolated child workspaces per product/build-target
* 3. Each child workspace gets its own submodule profile, LFS hydration, and Library folder
* 4. After build, the child workspace (.git preserved) is moved to a parent-level backup directory
* 5. On next CI run, the child workspace is restored via atomic filesystem move (O(1) on NTFS)
* 6. Library folders are cached separately for independent restore
*
* This is orders of magnitude faster than actions/cache for 50GB+ workspaces.
*/
export class ChildWorkspaceService {
/**
* Initialize child workspace by restoring from cache if available.
* Uses atomic filesystem move (rename) for O(1) restore on same volume.
*
* @param projectPath - Target path where the workspace should live during build
* @param config - Child workspace configuration
* @returns true if restored from cache, false if starting fresh
*/
static initializeWorkspace(projectPath: string, config: ChildWorkspaceConfig): boolean {
const cachedWorkspacePath = path.join(config.parentCacheRoot, config.workspaceName);
try {
if (!fs.existsSync(cachedWorkspacePath)) {
OrchestratorLogger.log(`[ChildWorkspace] No cached workspace found at ${cachedWorkspacePath}, starting fresh`);
return false;
}
// Verify the cached workspace has content
const entries = fs.readdirSync(cachedWorkspacePath);
if (entries.length === 0) {
OrchestratorLogger.log(`[ChildWorkspace] Cached workspace at ${cachedWorkspacePath} is empty, starting fresh`);
fs.rmSync(cachedWorkspacePath, { recursive: true, force: true });
return false;
}
// If the target path already exists, remove it to make way for the move
if (fs.existsSync(projectPath)) {
OrchestratorLogger.log(`[ChildWorkspace] Removing existing target path: ${projectPath}`);
fs.rmSync(projectPath, { recursive: true, force: true });
}
// Ensure parent directory of project path exists
const parentDirectory = path.dirname(projectPath);
if (!fs.existsSync(parentDirectory)) {
fs.mkdirSync(parentDirectory, { recursive: true });
}
// Atomic move (rename) - O(1) on NTFS when on same volume
OrchestratorLogger.log(`[ChildWorkspace] Restoring workspace: ${cachedWorkspacePath} -> ${projectPath}`);
fs.renameSync(cachedWorkspacePath, projectPath);
OrchestratorLogger.log(`[ChildWorkspace] Workspace restored via atomic move`);
// Restore Library cache separately if configured
if (config.separateLibraryCache) {
ChildWorkspaceService.restoreLibraryCache(projectPath, config);
}
return true;
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Workspace restore failed: ${error.message}. Starting fresh.`);
return false;
}
}
/**
* Save child workspace after build for reuse on next CI run.
* Moves the entire workspace to the cache directory via atomic filesystem move.
*
* @param projectPath - Path to the workspace to save
* @param config - Child workspace configuration
*/
static saveWorkspace(projectPath: string, config: ChildWorkspaceConfig): void {
const cachedWorkspacePath = path.join(config.parentCacheRoot, config.workspaceName);
try {
if (!fs.existsSync(projectPath)) {
OrchestratorLogger.log(`[ChildWorkspace] Project path ${projectPath} does not exist, skipping save`);
return;
}
// Remove .git directory if not preserving it (saves space but loses delta capability)
if (!config.preserveGitDirectory) {
const gitDirectory = path.join(projectPath, '.git');
if (fs.existsSync(gitDirectory)) {
OrchestratorLogger.log(`[ChildWorkspace] Removing .git directory (preserveGit=false)`);
fs.rmSync(gitDirectory, { recursive: true, force: true });
}
}
// If separateLibraryCache, move Library/ to its own backup path before saving workspace
if (config.separateLibraryCache) {
ChildWorkspaceService.saveLibraryCache(projectPath, config);
}
// Ensure parent cache root exists
if (!fs.existsSync(config.parentCacheRoot)) {
fs.mkdirSync(config.parentCacheRoot, { recursive: true });
}
// Remove any existing cached workspace to make room
if (fs.existsSync(cachedWorkspacePath)) {
OrchestratorLogger.log(`[ChildWorkspace] Removing previous cached workspace: ${cachedWorkspacePath}`);
fs.rmSync(cachedWorkspacePath, { recursive: true, force: true });
}
// Atomic move (rename) - O(1) on NTFS when on same volume
OrchestratorLogger.log(`[ChildWorkspace] Saving workspace: ${projectPath} -> ${cachedWorkspacePath}`);
fs.renameSync(projectPath, cachedWorkspacePath);
OrchestratorLogger.log(`[ChildWorkspace] Workspace saved via atomic move`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Workspace save failed: ${error.message}`);
}
}
/**
* Restore Library folder from separate cache location.
*
* @param projectPath - Path to the workspace where Library should be restored
* @param config - Child workspace configuration
* @returns true if Library was restored from cache
*/
static restoreLibraryCache(projectPath: string, config: ChildWorkspaceConfig): boolean {
const libraryBackup = ChildWorkspaceService.resolveLibraryBackupPath(config);
const libraryDestination = path.join(projectPath, 'Library');
try {
if (!fs.existsSync(libraryBackup)) {
OrchestratorLogger.log(`[ChildWorkspace] No Library cache found at ${libraryBackup}`);
return false;
}
const entries = fs.readdirSync(libraryBackup);
if (entries.length === 0) {
OrchestratorLogger.log(`[ChildWorkspace] Library cache at ${libraryBackup} is empty`);
fs.rmSync(libraryBackup, { recursive: true, force: true });
return false;
}
// Remove existing Library directory if present
if (fs.existsSync(libraryDestination)) {
fs.rmSync(libraryDestination, { recursive: true, force: true });
}
// Atomic move
OrchestratorLogger.log(`[ChildWorkspace] Restoring Library cache: ${libraryBackup} -> ${libraryDestination}`);
fs.renameSync(libraryBackup, libraryDestination);
OrchestratorLogger.log(`[ChildWorkspace] Library cache restored`);
return true;
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Library cache restore failed: ${error.message}`);
return false;
}
}
/**
* Save Library folder to a separate cache location for independent restore.
* Moves Library/ out of the workspace before workspace save.
*
* @param projectPath - Path to the workspace containing Library/
* @param config - Child workspace configuration
*/
private static saveLibraryCache(projectPath: string, config: ChildWorkspaceConfig): void {
const libraryPath = path.join(projectPath, 'Library');
const libraryBackup = ChildWorkspaceService.resolveLibraryBackupPath(config);
try {
if (!fs.existsSync(libraryPath)) {
OrchestratorLogger.log(`[ChildWorkspace] No Library folder to cache`);
return;
}
const entries = fs.readdirSync(libraryPath);
if (entries.length === 0) {
OrchestratorLogger.log(`[ChildWorkspace] Library folder is empty, skipping cache`);
return;
}
// Ensure parent of backup path exists
const backupParent = path.dirname(libraryBackup);
if (!fs.existsSync(backupParent)) {
fs.mkdirSync(backupParent, { recursive: true });
}
// Remove existing Library backup
if (fs.existsSync(libraryBackup)) {
fs.rmSync(libraryBackup, { recursive: true, force: true });
}
// Atomic move
OrchestratorLogger.log(`[ChildWorkspace] Caching Library: ${libraryPath} -> ${libraryBackup}`);
fs.renameSync(libraryPath, libraryBackup);
OrchestratorLogger.log(`[ChildWorkspace] Library cached separately`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Library cache save failed: ${error.message}`);
}
}
/**
* Calculate the total size of a directory in human-readable format.
*
* @param directoryPath - Path to the directory to measure
* @returns Human-readable size string (e.g., "1.23 GB", "456.78 MB")
*/
static getWorkspaceSize(directoryPath: string): string {
try {
if (!fs.existsSync(directoryPath)) {
return '0 B';
}
const totalBytes = ChildWorkspaceService.calculateDirectorySize(directoryPath);
return ChildWorkspaceService.formatBytes(totalBytes);
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Failed to calculate workspace size: ${error.message}`);
return 'unknown';
}
}
/**
* Clean stale child workspaces that haven't been used within the retention period.
*
* @param parentCacheRoot - Root directory containing cached workspaces
* @param retentionDays - Maximum age in days before a workspace is considered stale
*/
static cleanStaleWorkspaces(parentCacheRoot: string, retentionDays: number): void {
try {
if (!fs.existsSync(parentCacheRoot)) {
OrchestratorLogger.log(`[ChildWorkspace] Cache root ${parentCacheRoot} does not exist, nothing to clean`);
return;
}
const now = Date.now();
const maxAgeMs = retentionDays * 24 * 60 * 60 * 1000;
const entries = fs.readdirSync(parentCacheRoot);
let removedCount = 0;
let freedBytes = 0;
for (const entry of entries) {
const entryPath = path.join(parentCacheRoot, entry);
try {
const stat = fs.statSync(entryPath);
if (stat.isDirectory() && now - stat.mtimeMs > maxAgeMs) {
const size = ChildWorkspaceService.calculateDirectorySize(entryPath);
fs.rmSync(entryPath, { recursive: true, force: true });
removedCount++;
freedBytes += size;
OrchestratorLogger.log(
`[ChildWorkspace] Cleaned stale workspace: ${entry} (age: ${Math.floor(
(now - stat.mtimeMs) / (24 * 60 * 60 * 1000),
)} days)`,
);
}
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Failed to clean ${entryPath}: ${error.message}`);
}
}
OrchestratorLogger.log(
`[ChildWorkspace] Cleanup complete: ${removedCount} stale workspaces removed, ${ChildWorkspaceService.formatBytes(
freedBytes,
)} freed`,
);
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Stale workspace cleanup failed: ${error.message}`);
}
}
/**
* Build a ChildWorkspaceConfig from build parameters and action inputs.
*/
static buildConfig(parameters: {
childWorkspacesEnabled: boolean;
childWorkspaceName: string;
childWorkspaceCacheRoot: string;
childWorkspacePreserveGit: boolean;
childWorkspaceSeparateLibrary: boolean;
}): ChildWorkspaceConfig {
return {
enabled: parameters.childWorkspacesEnabled,
workspaceName: parameters.childWorkspaceName,
parentCacheRoot: parameters.childWorkspaceCacheRoot,
preserveGitDirectory: parameters.childWorkspacePreserveGit,
separateLibraryCache: parameters.childWorkspaceSeparateLibrary,
};
}
/**
* Resolve the Library backup path from config, using a default if not overridden.
*/
private static resolveLibraryBackupPath(config: ChildWorkspaceConfig): string {
if (config.libraryBackupPath) {
return config.libraryBackupPath;
}
return path.join(config.parentCacheRoot, `${config.workspaceName}-Library`);
}
/**
* Recursively calculate total size of a directory in bytes.
*/
private static calculateDirectorySize(directoryPath: string): number {
let totalSize = 0;
try {
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
totalSize += ChildWorkspaceService.calculateDirectorySize(fullPath);
} else if (entry.isFile()) {
totalSize += fs.statSync(fullPath).size;
}
}
} catch {
// Permission errors or race conditions — return what we have
}
return totalSize;
}
/**
* Format bytes into human-readable string.
*/
private static formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const index = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, index);
return `${value.toFixed(2)} ${units[index]}`;
}
}

View File

@@ -1,272 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { LocalCacheService } from './local-cache-service';
jest.mock('node:fs');
jest.mock('../core/orchestrator-system', () => ({
OrchestratorSystem: {
Run: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
error: jest.fn(),
},
}));
const mockFs = fs as jest.Mocked<typeof fs>;
describe('LocalCacheService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('generateCacheKey', () => {
it('should generate a key from platform, version, and branch', () => {
const key = LocalCacheService.generateCacheKey('StandaloneLinux64', '2021.3.1f1', 'main');
expect(key).toBe('StandaloneLinux64-2021_3_1f1-main');
});
it('should sanitize non-alphanumeric characters except hyphens', () => {
const key = LocalCacheService.generateCacheKey('WebGL', '2022.3.0f1', 'feature/my-branch');
expect(key).toBe('WebGL-2022_3_0f1-feature_my-branch');
});
it('should handle empty branch', () => {
const key = LocalCacheService.generateCacheKey('StandaloneWindows64', '2021.3.1f1', '');
expect(key).toBe('StandaloneWindows64-2021_3_1f1-');
});
it('should handle dots in version string', () => {
const key = LocalCacheService.generateCacheKey('Android', '6000.0.23f1', 'main');
expect(key).toBe('Android-6000_0_23f1-main');
});
it('should preserve hyphens in platform names', () => {
const key = LocalCacheService.generateCacheKey('Standalone-Linux64', '2021.3.1f1', 'main');
expect(key).toBe('Standalone-Linux64-2021_3_1f1-main');
});
});
describe('resolveCacheRoot', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should use localCacheRoot when set', () => {
const result = LocalCacheService.resolveCacheRoot({ localCacheRoot: '/custom/cache' });
expect(result).toBe('/custom/cache');
});
it('should use RUNNER_TEMP when localCacheRoot is empty', () => {
process.env.RUNNER_TEMP = '/tmp/runner';
const result = LocalCacheService.resolveCacheRoot({ localCacheRoot: '' });
expect(result).toBe(path.join('/tmp/runner', 'game-ci-cache'));
});
it('should fall back to .game-ci/cache when neither is set', () => {
delete process.env.RUNNER_TEMP;
const result = LocalCacheService.resolveCacheRoot({ localCacheRoot: '' });
expect(result).toBe(path.join(process.cwd(), '.game-ci', 'cache'));
});
});
describe('restoreLibraryCache', () => {
it('should return false on cache miss (directory does not exist)', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = await LocalCacheService.restoreLibraryCache('/project', '/cache', 'key1');
expect(result).toBe(false);
});
it('should return false when cache directory has no tar files', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['readme.txt', 'info.json']);
const result = await LocalCacheService.restoreLibraryCache('/project', '/cache', 'key1');
expect(result).toBe(false);
});
it('should restore from the latest tar file on cache hit', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['lib-1000.tar', 'lib-2000.tar']);
(mockFs.statSync as jest.Mock).mockImplementation((filePath: string) => ({
mtimeMs: String(filePath).includes('lib-2000') ? 2000 : 1000,
}));
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
const { OrchestratorSystem } = require('../core/orchestrator-system');
const result = await LocalCacheService.restoreLibraryCache('/project', '/cache', 'key1');
expect(result).toBe(true);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('lib-2000.tar'), true);
});
it('should return false and log warning on error', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockImplementation(() => {
throw new Error('Permission denied');
});
const result = await LocalCacheService.restoreLibraryCache('/project', '/cache', 'key1');
expect(result).toBe(false);
});
});
describe('saveLibraryCache', () => {
it('should skip save when Library folder does not exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
await LocalCacheService.saveLibraryCache('/project', '/cache', 'key1');
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
});
it('should skip save when Library folder is empty', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue([]);
await LocalCacheService.saveLibraryCache('/project', '/cache', 'key1');
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
});
it('should create cache directory and save tar', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockImplementation((dirPath: string) => {
if (String(dirPath).includes('Library') && !String(dirPath).includes('cache')) {
return ['file1.asset', 'file2.asset'];
}
return [];
});
(mockFs.statSync as jest.Mock).mockReturnValue({ mtimeMs: Date.now() });
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('');
await LocalCacheService.saveLibraryCache('/project', '/cache', 'key1');
expect(mockFs.mkdirSync).toHaveBeenCalledWith(path.join('/cache', 'key1', 'Library'), { recursive: true });
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('tar -cf'), true);
});
});
describe('restoreLfsCache', () => {
it('should return false on cache miss', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = await LocalCacheService.restoreLfsCache('/repo', '/cache', 'key1');
expect(result).toBe(false);
});
it('should return false when no tar files exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['readme.txt']);
const result = await LocalCacheService.restoreLfsCache('/repo', '/cache', 'key1');
expect(result).toBe(false);
});
it('should restore from latest tar on hit', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['lfs-100.tar', 'lfs-200.tar']);
(mockFs.statSync as jest.Mock).mockImplementation((filePath: string) => ({
mtimeMs: String(filePath).includes('lfs-200') ? 200 : 100,
}));
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
const { OrchestratorSystem } = require('../core/orchestrator-system');
const result = await LocalCacheService.restoreLfsCache('/repo', '/cache', 'key1');
expect(result).toBe(true);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('lfs-200.tar'), true);
});
});
describe('saveLfsCache', () => {
it('should skip when .git/lfs does not exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
await LocalCacheService.saveLfsCache('/repo', '/cache', 'key1');
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
});
it('should skip when .git/lfs is empty', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue([]);
await LocalCacheService.saveLfsCache('/repo', '/cache', 'key1');
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
});
it('should create cache directory and save tar when lfs has content', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockImplementation((dirPath: string) => {
if (String(dirPath).includes('lfs') && !String(dirPath).includes('cache')) {
return ['objects', 'tmp'];
}
return [];
});
(mockFs.statSync as jest.Mock).mockReturnValue({ mtimeMs: Date.now() });
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('');
await LocalCacheService.saveLfsCache('/repo', '/cache', 'key1');
expect(mockFs.mkdirSync).toHaveBeenCalledWith(path.join('/cache', 'key1', 'lfs'), { recursive: true });
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('tar -cf'), true);
});
it('should handle save errors gracefully', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockImplementation(() => {
throw new Error('Disk full');
});
// Should not throw
await LocalCacheService.saveLfsCache('/repo', '/cache', 'key1');
});
});
describe('garbageCollect', () => {
it('should skip when cache root does not exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
await LocalCacheService.garbageCollect('/nonexistent');
});
it('should remove directories older than maxAgeDays', async () => {
const now = Date.now();
const eightDaysAgo = now - 8 * 24 * 60 * 60 * 1000;
const oneDayAgo = now - 1 * 24 * 60 * 60 * 1000;
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['old-cache', 'recent-cache']);
(mockFs.statSync as jest.Mock).mockImplementation((filePath: string) => ({
isDirectory: () => true,
mtimeMs: String(filePath).includes('old') ? eightDaysAgo : oneDayAgo,
}));
(mockFs.rmSync as jest.Mock).mockReturnValue(undefined);
await LocalCacheService.garbageCollect('/cache', 7);
expect(mockFs.rmSync).toHaveBeenCalledTimes(1);
expect(mockFs.rmSync).toHaveBeenCalledWith(path.join('/cache', 'old-cache'), { recursive: true, force: true });
});
it('should not remove directories newer than maxAgeDays', async () => {
const oneDayAgo = Date.now() - 1 * 24 * 60 * 60 * 1000;
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['recent-cache']);
(mockFs.statSync as jest.Mock).mockReturnValue({
isDirectory: () => true,
mtimeMs: oneDayAgo,
});
await LocalCacheService.garbageCollect('/cache', 7);
expect(mockFs.rmSync).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,273 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { OrchestratorSystem } from '../core/orchestrator-system';
import OrchestratorLogger from '../core/orchestrator-logger';
export class LocalCacheService {
/**
* Resolve the cache root directory based on build parameters and environment.
* Priority: localCacheRoot > RUNNER_TEMP/game-ci-cache > .game-ci/cache
*/
static resolveCacheRoot(buildParameters: { localCacheRoot: string }): string {
if (buildParameters.localCacheRoot) {
return buildParameters.localCacheRoot;
}
if (process.env.RUNNER_TEMP) {
return path.join(process.env.RUNNER_TEMP, 'game-ci-cache');
}
return path.join(process.cwd(), '.game-ci', 'cache');
}
/**
* Generate a sanitized cache key from build parameters.
* Non-alphanumeric characters (except hyphens) are replaced with underscores.
*/
static generateCacheKey(targetPlatform: string, unityVersion: string, branch: string): string {
const raw = `${targetPlatform}-${unityVersion}-${branch}`;
return raw.replace(/[^a-zA-Z0-9-]/g, '_');
}
/**
* Restore Unity Library cache from the local filesystem.
* Returns true if cache was restored, false on cache miss.
*/
static async restoreLibraryCache(projectPath: string, cacheRoot: string, cacheKey: string): Promise<boolean> {
const cachePath = path.join(cacheRoot, cacheKey, 'Library');
try {
if (!fs.existsSync(cachePath)) {
OrchestratorLogger.log(`[LocalCache] Library cache miss: ${cachePath}`);
return false;
}
const files = fs.readdirSync(cachePath).filter((f) => f.endsWith('.tar'));
if (files.length === 0) {
OrchestratorLogger.log(`[LocalCache] Library cache miss (no tar files): ${cachePath}`);
return false;
}
// Find the latest tar file by modification time
let latestFile = files[0];
let latestMtime = fs.statSync(path.join(cachePath, files[0])).mtimeMs;
for (let i = 1; i < files.length; i++) {
const mtime = fs.statSync(path.join(cachePath, files[i])).mtimeMs;
if (mtime > latestMtime) {
latestMtime = mtime;
latestFile = files[i];
}
}
const tarPath = path.join(cachePath, latestFile);
const libraryDest = path.join(projectPath, 'Library');
// Ensure destination exists
fs.mkdirSync(libraryDest, { recursive: true });
OrchestratorLogger.log(`[LocalCache] Library cache hit: restoring from ${tarPath}`);
await OrchestratorSystem.Run(`tar -xf "${tarPath}" -C "${projectPath}"`, true);
OrchestratorLogger.log(`[LocalCache] Library cache restored successfully`);
return true;
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] Library cache restore failed: ${error.message}`);
return false;
}
}
/**
* Save Unity Library folder to the local cache as a tar archive.
* Keeps only the latest 2 cache entries.
*/
static async saveLibraryCache(projectPath: string, cacheRoot: string, cacheKey: string): Promise<void> {
const libraryPath = path.join(projectPath, 'Library');
try {
if (!fs.existsSync(libraryPath)) {
OrchestratorLogger.log(`[LocalCache] Library folder does not exist, skipping save`);
return;
}
const entries = fs.readdirSync(libraryPath);
if (entries.length === 0) {
OrchestratorLogger.log(`[LocalCache] Library folder is empty, skipping save`);
return;
}
const cachePath = path.join(cacheRoot, cacheKey, 'Library');
fs.mkdirSync(cachePath, { recursive: true });
const timestamp = Date.now();
const tarName = `lib-${timestamp}.tar`;
const tarPath = path.join(cachePath, tarName);
OrchestratorLogger.log(`[LocalCache] Saving Library cache to ${tarPath}`);
await OrchestratorSystem.Run(`tar -cf "${tarPath}" -C "${projectPath}" Library`, true);
OrchestratorLogger.log(`[LocalCache] Library cache saved successfully`);
// Clean up old entries - keep latest 2
await LocalCacheService.cleanupOldEntries(cachePath, 2);
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] Library cache save failed: ${error.message}`);
}
}
/**
* Restore LFS cache from the local filesystem.
* Returns true if cache was restored, false on cache miss.
*/
static async restoreLfsCache(repoPath: string, cacheRoot: string, cacheKey: string): Promise<boolean> {
const cachePath = path.join(cacheRoot, cacheKey, 'lfs');
try {
if (!fs.existsSync(cachePath)) {
OrchestratorLogger.log(`[LocalCache] LFS cache miss: ${cachePath}`);
return false;
}
const files = fs.readdirSync(cachePath).filter((f) => f.endsWith('.tar'));
if (files.length === 0) {
OrchestratorLogger.log(`[LocalCache] LFS cache miss (no tar files): ${cachePath}`);
return false;
}
// Find the latest tar file by modification time
let latestFile = files[0];
let latestMtime = fs.statSync(path.join(cachePath, files[0])).mtimeMs;
for (let i = 1; i < files.length; i++) {
const mtime = fs.statSync(path.join(cachePath, files[i])).mtimeMs;
if (mtime > latestMtime) {
latestMtime = mtime;
latestFile = files[i];
}
}
const tarPath = path.join(cachePath, latestFile);
const lfsDest = path.join(repoPath, '.git', 'lfs');
// Ensure destination exists
fs.mkdirSync(lfsDest, { recursive: true });
OrchestratorLogger.log(`[LocalCache] LFS cache hit: restoring from ${tarPath}`);
await OrchestratorSystem.Run(`tar -xf "${tarPath}" -C "${path.join(repoPath, '.git')}"`, true);
OrchestratorLogger.log(`[LocalCache] LFS cache restored successfully`);
return true;
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] LFS cache restore failed: ${error.message}`);
return false;
}
}
/**
* Save .git/lfs folder to the local cache as a tar archive.
* Keeps only the latest 2 cache entries.
*/
static async saveLfsCache(repoPath: string, cacheRoot: string, cacheKey: string): Promise<void> {
const lfsPath = path.join(repoPath, '.git', 'lfs');
try {
if (!fs.existsSync(lfsPath)) {
OrchestratorLogger.log(`[LocalCache] LFS folder does not exist, skipping save`);
return;
}
const entries = fs.readdirSync(lfsPath);
if (entries.length === 0) {
OrchestratorLogger.log(`[LocalCache] LFS folder is empty, skipping save`);
return;
}
const cachePath = path.join(cacheRoot, cacheKey, 'lfs');
fs.mkdirSync(cachePath, { recursive: true });
const timestamp = Date.now();
const tarName = `lfs-${timestamp}.tar`;
const tarPath = path.join(cachePath, tarName);
OrchestratorLogger.log(`[LocalCache] Saving LFS cache to ${tarPath}`);
await OrchestratorSystem.Run(`tar -cf "${tarPath}" -C "${path.join(repoPath, '.git')}" lfs`, true);
OrchestratorLogger.log(`[LocalCache] LFS cache saved successfully`);
// Clean up old entries - keep latest 2
await LocalCacheService.cleanupOldEntries(cachePath, 2);
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] LFS cache save failed: ${error.message}`);
}
}
/**
* Remove cache entries older than maxAgeDays from the cache root.
*/
static async garbageCollect(cacheRoot: string, maxAgeDays: number = 7): Promise<void> {
try {
if (!fs.existsSync(cacheRoot)) {
OrchestratorLogger.log(`[LocalCache] Cache root does not exist, nothing to collect`);
return;
}
const now = Date.now();
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
const entries = fs.readdirSync(cacheRoot);
let removedCount = 0;
for (const entry of entries) {
const entryPath = path.join(cacheRoot, entry);
try {
const stat = fs.statSync(entryPath);
if (stat.isDirectory() && now - stat.mtimeMs > maxAgeMs) {
fs.rmSync(entryPath, { recursive: true, force: true });
removedCount++;
OrchestratorLogger.log(`[LocalCache] Garbage collected: ${entryPath}`);
}
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] Failed to garbage collect ${entryPath}: ${error.message}`);
}
}
OrchestratorLogger.log(`[LocalCache] Garbage collection complete: ${removedCount} entries removed`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] Garbage collection failed: ${error.message}`);
}
}
/**
* Clean up old tar files in a cache directory, keeping only the latest N.
*/
private static async cleanupOldEntries(cachePath: string, keepCount: number): Promise<void> {
try {
const files = fs
.readdirSync(cachePath)
.filter((f) => f.endsWith('.tar'))
.map((f) => ({
name: f,
mtime: fs.statSync(path.join(cachePath, f)).mtimeMs,
}))
.sort((a, b) => b.mtime - a.mtime);
if (files.length > keepCount) {
const toRemove = files.slice(keepCount);
for (const file of toRemove) {
const filePath = path.join(cachePath, file.name);
fs.unlinkSync(filePath);
OrchestratorLogger.log(`[LocalCache] Cleaned up old cache entry: ${filePath}`);
}
}
} catch (error: any) {
OrchestratorLogger.logWarning(`[LocalCache] Cleanup of old entries failed: ${error.message}`);
}
}
}

View File

@@ -1,147 +0,0 @@
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

@@ -1,318 +0,0 @@
import { RunnerAvailabilityService } from './runner-availability-service';
// Mock @octokit/core
jest.mock('@octokit/core', () => ({
Octokit: jest.fn().mockImplementation(() => ({
request: jest.fn(),
})),
}));
jest.mock('./orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
error: jest.fn(),
},
}));
import { Octokit } from '@octokit/core';
const MockedOctokit = Octokit as jest.MockedClass<typeof Octokit>;
function createMockRunners(runners: Array<{ name: string; status: string; busy: boolean; labels: string[] }>) {
return runners.map((r, i) => ({
id: i + 1,
name: r.name,
status: r.status,
busy: r.busy,
labels: r.labels.map((l) => ({ name: l })),
}));
}
describe('RunnerAvailabilityService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('checkAvailability', () => {
it('should skip check and not fallback when no token is provided', async () => {
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', '', [], 1);
expect(result.shouldFallback).toBe(false);
expect(result.reason).toContain('No GitHub token');
});
it('should fallback when no runners are registered', async () => {
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners: [] } });
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
expect(result.shouldFallback).toBe(true);
expect(result.reason).toContain('No runners registered');
expect(result.totalRunners).toBe(0);
});
it('should not fallback when enough idle runners are available', async () => {
const runners = createMockRunners([
{ name: 'runner-1', status: 'online', busy: false, labels: ['self-hosted', 'linux'] },
{ name: 'runner-2', status: 'online', busy: false, labels: ['self-hosted', 'linux'] },
]);
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
expect(result.shouldFallback).toBe(false);
expect(result.idleRunners).toBe(2);
expect(result.totalRunners).toBe(2);
});
it('should fallback when all runners are busy', async () => {
const runners = createMockRunners([
{ name: 'runner-1', status: 'online', busy: true, labels: ['self-hosted'] },
{ name: 'runner-2', status: 'online', busy: true, labels: ['self-hosted'] },
]);
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
expect(result.shouldFallback).toBe(true);
expect(result.idleRunners).toBe(0);
expect(result.matchingRunners).toBe(2);
});
it('should fallback when all runners are offline', async () => {
const runners = createMockRunners([
{ name: 'runner-1', status: 'offline', busy: false, labels: ['self-hosted'] },
]);
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
expect(result.shouldFallback).toBe(true);
expect(result.idleRunners).toBe(0);
});
it('should filter runners by required labels', async () => {
const runners = createMockRunners([
{ name: 'linux-runner', status: 'online', busy: false, labels: ['self-hosted', 'linux'] },
{ name: 'windows-runner', status: 'online', busy: false, labels: ['self-hosted', 'windows'] },
]);
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
const result = await RunnerAvailabilityService.checkAvailability(
'owner',
'repo',
'token',
['self-hosted', 'linux'],
1,
);
expect(result.shouldFallback).toBe(false);
expect(result.matchingRunners).toBe(1);
expect(result.idleRunners).toBe(1);
expect(result.totalRunners).toBe(2);
});
it('should fallback when no runners match required labels', async () => {
const runners = createMockRunners([
{ name: 'windows-runner', status: 'online', busy: false, labels: ['self-hosted', 'windows'] },
]);
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
const result = await RunnerAvailabilityService.checkAvailability(
'owner',
'repo',
'token',
['self-hosted', 'linux'],
1,
);
expect(result.shouldFallback).toBe(true);
expect(result.matchingRunners).toBe(0);
expect(result.idleRunners).toBe(0);
});
it('should respect minAvailable threshold', async () => {
const runners = createMockRunners([{ name: 'runner-1', status: 'online', busy: false, labels: ['self-hosted'] }]);
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
// Need 2, have 1 — should fallback
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 2);
expect(result.shouldFallback).toBe(true);
expect(result.idleRunners).toBe(1);
});
it('should be case-insensitive for label matching', async () => {
const runners = createMockRunners([
{ name: 'runner-1', status: 'online', busy: false, labels: ['Self-Hosted', 'Linux'] },
]);
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
const result = await RunnerAvailabilityService.checkAvailability(
'owner',
'repo',
'token',
['self-hosted', 'linux'],
1,
);
expect(result.shouldFallback).toBe(false);
expect(result.matchingRunners).toBe(1);
});
it('should not fallback on API error (fail-open)', async () => {
const mockRequest = jest.fn().mockRejectedValue(new Error('403 Forbidden'));
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
expect(result.shouldFallback).toBe(false);
expect(result.reason).toContain('Runner check failed');
});
it('should count only online+idle runners', async () => {
const runners = createMockRunners([
{ name: 'idle', status: 'online', busy: false, labels: ['self-hosted'] },
{ name: 'busy', status: 'online', busy: true, labels: ['self-hosted'] },
{ name: 'offline', status: 'offline', busy: false, labels: ['self-hosted'] },
]);
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
expect(result.shouldFallback).toBe(false);
expect(result.totalRunners).toBe(3);
expect(result.matchingRunners).toBe(3);
expect(result.idleRunners).toBe(1);
});
});
describe('pagination limits', () => {
it('should stop paginating after reaching the page limit', async () => {
// Return full pages (100 runners each) to force continued pagination
let callCount = 0;
const mockRequest = jest.fn().mockImplementation(() => {
callCount++;
const runners = createMockRunners(
Array.from({ length: 100 }, (_, i) => ({
name: `runner-${callCount}-${i}`,
status: 'online' as const,
busy: false,
labels: ['self-hosted'],
})),
);
return Promise.resolve({ status: 200, data: { runners } });
});
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
// Should have called at most 100 pages (the MAX_PAGINATION_PAGES limit)
expect(mockRequest).toHaveBeenCalledTimes(100);
// Should still have runners from the pages it did fetch
expect(result.totalRunners).toBe(10000);
expect(result.shouldFallback).toBe(false);
});
it('should stop paginating on rate limit (HTTP 403)', async () => {
let callCount = 0;
const mockRequest = jest.fn().mockImplementation(() => {
callCount++;
if (callCount === 2) {
// Octokit throws for non-2xx responses
const error: any = new Error('API rate limit exceeded');
error.status = 403;
error.response = {
status: 403,
headers: { 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 3600) },
};
return Promise.reject(error);
}
const runners = createMockRunners(
Array.from({ length: 100 }, (_, i) => ({
name: `runner-${i}`,
status: 'online' as const,
busy: false,
labels: ['self-hosted'],
})),
);
return Promise.resolve({ status: 200, data: { runners } });
});
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
// Should have stopped at page 2 (rate limited)
expect(mockRequest).toHaveBeenCalledTimes(2);
// Should use the 100 runners from the first page
expect(result.totalRunners).toBe(100);
expect(result.shouldFallback).toBe(false);
});
it('should stop paginating on rate limit (HTTP 429)', async () => {
let callCount = 0;
const mockRequest = jest.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
// Octokit throws for non-2xx responses
const error: any = new Error('Too Many Requests');
error.status = 429;
error.response = { status: 429, headers: {} };
return Promise.reject(error);
}
return Promise.resolve({ status: 200, data: { runners: [] } });
});
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
// Should have stopped at first page (rate limited immediately)
expect(mockRequest).toHaveBeenCalledTimes(1);
// No runners found — should fallback
expect(result.totalRunners).toBe(0);
expect(result.shouldFallback).toBe(true);
});
it('should handle pagination timeout gracefully', async () => {
// Mock Date.now to simulate timeout
const originalDateNow = Date.now;
let callCount = 0;
const mockRequest = jest.fn().mockImplementation(() => {
callCount++;
// After first call, advance time past the timeout
if (callCount >= 2) {
Date.now = jest.fn(() => originalDateNow() + 31_000);
}
const runners = createMockRunners(
Array.from({ length: 100 }, (_, i) => ({
name: `runner-${callCount}-${i}`,
status: 'online' as const,
busy: false,
labels: ['self-hosted'],
})),
);
return Promise.resolve({ status: 200, data: { runners } });
});
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
// Should have stopped after timeout was detected (2 pages: first succeeds, second triggers timeout check)
expect(mockRequest.mock.calls.length).toBeLessThanOrEqual(3);
// Should have runners from pages fetched before timeout
expect(result.totalRunners).toBeGreaterThan(0);
// Restore
Date.now = originalDateNow;
});
});
});

View File

@@ -1,205 +0,0 @@
import { Octokit } from '@octokit/core';
import OrchestratorLogger from './orchestrator-logger';
interface GitHubRunner {
id: number;
name: string;
status: 'online' | 'offline';
busy: boolean;
labels: Array<{ name: string }>;
}
interface RunnerCheckResult {
shouldFallback: boolean;
reason: string;
totalRunners: number;
matchingRunners: number;
idleRunners: number;
}
/**
* Maximum number of pages to fetch when paginating through GitHub API results.
* 100 pages * 100 per page = 10,000 runners maximum.
*/
const MAX_PAGINATION_PAGES = 100;
/**
* Total timeout in milliseconds for the pagination loop.
* Prevents indefinite API calls if GitHub is slow or pagination is unexpectedly deep.
*/
const PAGINATION_TIMEOUT_MS = 30_000;
/**
* Checks GitHub Actions runner availability to support automatic provider fallback.
*
* When a user configures `runnerCheckEnabled: true` with a `fallbackProviderStrategy`,
* this service queries the GitHub API for runner status before the build starts.
* If insufficient runners are available, the orchestrator routes to the fallback provider.
*/
export class RunnerAvailabilityService {
/**
* Check if enough runners are available to handle the build.
*
* @param owner - GitHub repository owner
* @param repo - GitHub repository name
* @param token - GitHub token with repo/actions scope
* @param requiredLabels - Labels runners must have (empty = any runner)
* @param minAvailable - Minimum idle runners required
* @returns RunnerCheckResult with decision and diagnostics
*/
static async checkAvailability(
owner: string,
repo: string,
token: string,
requiredLabels: string[],
minAvailable: number,
): Promise<RunnerCheckResult> {
if (!token) {
return {
shouldFallback: false,
reason: 'No GitHub token available — skipping runner check',
totalRunners: 0,
matchingRunners: 0,
idleRunners: 0,
};
}
try {
const octokit = new Octokit({ auth: token });
// Fetch all runners for the repository
const runners = await RunnerAvailabilityService.fetchRunners(octokit, owner, repo);
if (runners.length === 0) {
return {
shouldFallback: true,
reason: 'No runners registered for this repository',
totalRunners: 0,
matchingRunners: 0,
idleRunners: 0,
};
}
// Filter by required labels
const matching = RunnerAvailabilityService.filterByLabels(runners, requiredLabels);
// Count idle (online + not busy)
const idle = matching.filter((r) => r.status === 'online' && !r.busy);
const result: RunnerCheckResult = {
shouldFallback: idle.length < minAvailable,
reason:
idle.length >= minAvailable
? `${idle.length} idle runner(s) available (need ${minAvailable})`
: `Only ${idle.length} idle runner(s) available, need ${minAvailable}`,
totalRunners: runners.length,
matchingRunners: matching.length,
idleRunners: idle.length,
};
return result;
} catch (error: any) {
// If the API call fails (permissions, rate limit, etc.), don't block the build
OrchestratorLogger.log(`Runner availability check failed: ${error.message}`);
return {
shouldFallback: false,
reason: `Runner check failed (${error.message}) — proceeding with primary provider`,
totalRunners: 0,
matchingRunners: 0,
idleRunners: 0,
};
}
}
/**
* Fetch all runners for a repository, handling pagination.
*
* Includes defensive limits:
* - Maximum page count (MAX_PAGINATION_PAGES) to prevent infinite loops
* - Total timeout (PAGINATION_TIMEOUT_MS) to prevent indefinite API calls
* - Rate-limit detection (HTTP 403/429 with X-RateLimit-Remaining header)
*/
private static async fetchRunners(octokit: Octokit, owner: string, repo: string): Promise<GitHubRunner[]> {
const allRunners: GitHubRunner[] = [];
let page = 1;
const perPage = 100;
const startTime = Date.now();
while (page <= MAX_PAGINATION_PAGES) {
// Check total timeout
if (Date.now() - startTime > PAGINATION_TIMEOUT_MS) {
OrchestratorLogger.logWarning(
`[RunnerAvailability] Pagination timeout reached after ${page - 1} pages and ${Date.now() - startTime}ms. ` +
`Using ${allRunners.length} runners found so far.`,
);
break;
}
let response: any;
try {
response = await octokit.request('GET /repos/{owner}/{repo}/actions/runners', {
owner,
repo,
per_page: perPage,
page,
});
} catch (requestError: any) {
// Octokit throws for non-2xx responses. Check if this is a rate limit error.
const status = requestError.status ?? requestError.response?.status;
if (status === 403 || status === 429) {
const resetTime =
requestError.response?.headers?.['x-ratelimit-reset'] ?? requestError.headers?.['x-ratelimit-reset'];
const resetMessage = resetTime
? ` Resets at ${new Date(Number.parseInt(String(resetTime), 10) * 1000).toISOString()}`
: '';
OrchestratorLogger.logWarning(
`[RunnerAvailability] GitHub API rate limit reached (HTTP ${status}).${resetMessage} ` +
`Using ${allRunners.length} runners found so far.`,
);
break;
}
// Re-throw non-rate-limit errors to be handled by the outer catch
throw requestError;
}
const runners = (response.data.runners || []) as GitHubRunner[];
allRunners.push(...runners);
if (runners.length < perPage) break;
page++;
}
if (page > MAX_PAGINATION_PAGES) {
OrchestratorLogger.logWarning(
`[RunnerAvailability] Maximum pagination limit reached (${MAX_PAGINATION_PAGES} pages). ` +
`Using ${allRunners.length} runners found so far.`,
);
}
if (allRunners.length === 0) {
OrchestratorLogger.log(
'[RunnerAvailability] No runners found. Possible causes: ' +
'wrong token permissions (needs repo or actions scope), ' +
'no self-hosted runners registered, ' +
'or runners are registered at the organization level instead of the repository.',
);
}
return allRunners;
}
/**
* Filter runners by required labels. A runner matches if it has ALL required labels.
* If requiredLabels is empty, all runners match.
*/
private static filterByLabels(runners: GitHubRunner[], requiredLabels: string[]): GitHubRunner[] {
if (requiredLabels.length === 0) return runners;
return runners.filter((runner) => {
const runnerLabelNames = runner.labels.map((l) => l.name.toLowerCase());
return requiredLabels.every((required) => runnerLabelNames.includes(required.toLowerCase()));
});
}
}

View File

@@ -1,207 +0,0 @@
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,405 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { GitHooksService } from './git-hooks-service';
// Mock dependencies
jest.mock('node:fs');
jest.mock('../core/orchestrator-system', () => ({
OrchestratorSystem: {
Run: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
error: jest.fn(),
},
}));
const mockFs = fs as jest.Mocked<typeof fs>;
describe('GitHooksService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('detectHookFramework', () => {
it('should detect lefthook.yml', () => {
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
});
const result = GitHooksService.detectHookFramework('/repo');
expect(result).toBe('lefthook');
});
it('should detect .lefthook.yml', () => {
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('.lefthook.yml');
});
const result = GitHooksService.detectHookFramework('/repo');
expect(result).toBe('lefthook');
});
it('should detect .husky directory', () => {
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).endsWith('.husky');
});
const result = GitHooksService.detectHookFramework('/repo');
expect(result).toBe('husky');
});
it('should return none when no framework is detected', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = GitHooksService.detectHookFramework('/repo');
expect(result).toBe('none');
});
});
describe('detectUnityGitHooks', () => {
it('should return true when package is in manifest.json', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(
JSON.stringify({
dependencies: {
'com.frostebite.unitygithooks': 'https://github.com/frostebite/UnityGitHooks.git#1.0.5',
},
}),
);
expect(GitHooksService.detectUnityGitHooks('/repo')).toBe(true);
});
it('should return false when package is not in manifest.json', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(
JSON.stringify({
dependencies: {
'com.unity.textmeshpro': '3.0.6',
},
}),
);
expect(GitHooksService.detectUnityGitHooks('/repo')).toBe(false);
});
it('should return false when manifest.json does not exist', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
expect(GitHooksService.detectUnityGitHooks('/repo')).toBe(false);
});
it('should return false on read error', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockImplementation(() => {
throw new Error('Permission denied');
});
expect(GitHooksService.detectUnityGitHooks('/repo')).toBe(false);
});
});
describe('findUnityGitHooksPackagePath', () => {
it('should find versioned package directory', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue([
'com.unity.textmeshpro@3.0.6',
'com.frostebite.unitygithooks@1.0.5',
'com.unity.ugui@1.0.0',
]);
const result = GitHooksService.findUnityGitHooksPackagePath('/repo');
expect(result).toContain('com.frostebite.unitygithooks@1.0.5');
});
it('should return empty string when package not in cache', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['com.unity.textmeshpro@3.0.6']);
const result = GitHooksService.findUnityGitHooksPackagePath('/repo');
expect(result).toBe('');
});
it('should return empty string when PackageCache does not exist', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = GitHooksService.findUnityGitHooksPackagePath('/repo');
expect(result).toBe('');
});
});
describe('initUnityGitHooks', () => {
it('should run the init script when found', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['com.frostebite.unitygithooks@1.0.5']);
await GitHooksService.initUnityGitHooks('/repo');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('init-unity-lefthook.js'), true);
});
it('should skip when package not found in cache', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
await GitHooksService.initUnityGitHooks('/repo');
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
});
it('should warn when init script does not exist', async () => {
const OrchestratorLogger = require('../core/orchestrator-logger').default;
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
// PackageCache dir exists, but init script doesn't
return !String(p).includes('init-unity-lefthook');
});
(mockFs.readdirSync as jest.Mock).mockReturnValue(['com.frostebite.unitygithooks@1.0.5']);
await GitHooksService.initUnityGitHooks('/repo');
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining('init script not found'));
});
it('should log warning on init failure', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
const OrchestratorLogger = require('../core/orchestrator-logger').default;
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['com.frostebite.unitygithooks@1.0.5']);
OrchestratorSystem.Run.mockRejectedValue(new Error('node not found'));
await GitHooksService.initUnityGitHooks('/repo');
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining('init failed'));
});
});
describe('configureUnityGitHooksCIEnv', () => {
it('should disable background project mode', () => {
const env = GitHooksService.configureUnityGitHooksCIEnv();
expect(env.UNITY_GITHOOKS_BACKGROUND_PROJECT_ENABLED).toBe('false');
});
it('should set CI=true', () => {
const env = GitHooksService.configureUnityGitHooksCIEnv();
expect(env.CI).toBe('true');
});
});
describe('installHooks', () => {
it('should run npx lefthook install when lefthook is detected', async () => {
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
});
const { OrchestratorSystem } = require('../core/orchestrator-system');
await GitHooksService.installHooks('/repo');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(`cd "/repo" && npx lefthook install`, true);
});
it('should init Unity Git Hooks before installing lefthook when detected', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
const OrchestratorLogger = require('../core/orchestrator-logger').default;
const callOrder: string[] = [];
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(
`{"dependencies":{"com.frostebite.unitygithooks":"https://github.com/frostebite/UnityGitHooks.git"}}`,
);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['com.frostebite.unitygithooks@1.0.5']);
OrchestratorSystem.Run.mockImplementation((cmd: string) => {
if (cmd.includes('init-unity-lefthook')) {
callOrder.push('init');
} else if (cmd.includes('lefthook install')) {
callOrder.push('install');
}
return Promise.resolve('');
});
await GitHooksService.installHooks('/repo');
// Init should happen before install
expect(callOrder).toEqual(['init', 'install']);
expect(OrchestratorLogger.log).toHaveBeenCalledWith(expect.stringContaining('Unity Git Hooks (UPM) detected'));
});
it('should set CI env vars when Unity Git Hooks detected', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(`{"dependencies":{"com.frostebite.unitygithooks":"1.0.5"}}`);
(mockFs.readdirSync as jest.Mock).mockReturnValue(['com.frostebite.unitygithooks@1.0.5']);
await GitHooksService.installHooks('/repo');
expect(process.env.UNITY_GITHOOKS_BACKGROUND_PROJECT_ENABLED).toBe('false');
});
it('should run npx husky install when husky is detected', async () => {
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).endsWith('.husky');
});
const { OrchestratorSystem } = require('../core/orchestrator-system');
await GitHooksService.installHooks('/repo');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(`cd "/repo" && npx husky install`, true);
});
it('should log and return when no framework is detected', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const { OrchestratorSystem } = require('../core/orchestrator-system');
const OrchestratorLogger = require('../core/orchestrator-logger').default;
await GitHooksService.installHooks('/repo');
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
expect(OrchestratorLogger.log).toHaveBeenCalledWith(expect.stringContaining('No hook framework detected'));
});
it('should log warning on installation failure', async () => {
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
});
const { OrchestratorSystem } = require('../core/orchestrator-system');
const OrchestratorLogger = require('../core/orchestrator-logger').default;
OrchestratorSystem.Run.mockRejectedValue(new Error('npx not found'));
await GitHooksService.installHooks('/repo');
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining('Hook installation failed'));
});
});
describe('runHookGroups', () => {
it('should run each group via lefthook run', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
});
OrchestratorSystem.Run.mockResolvedValue('');
const results = await GitHooksService.runHookGroups('/repo', ['pre-commit', 'pre-push']);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(`cd "/repo" && npx lefthook run pre-commit`, true);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(`cd "/repo" && npx lefthook run pre-push`, true);
expect(results['pre-commit']).toBe(true);
expect(results['pre-push']).toBe(true);
});
it('should return empty results for empty groups', async () => {
const results = await GitHooksService.runHookGroups('/repo', []);
expect(results).toEqual({});
});
it('should warn and return empty if not using lefthook', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const OrchestratorLogger = require('../core/orchestrator-logger').default;
const results = await GitHooksService.runHookGroups('/repo', ['pre-commit']);
expect(results).toEqual({});
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining('requires lefthook'));
});
it('should mark failed groups as false', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
});
OrchestratorSystem.Run.mockResolvedValueOnce('') // pre-commit passes
.mockRejectedValueOnce(new Error('tests failed')); // pre-push fails
const results = await GitHooksService.runHookGroups('/repo', ['pre-commit', 'pre-push']);
expect(results['pre-commit']).toBe(true);
expect(results['pre-push']).toBe(false);
});
it('should log each group result', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
const OrchestratorLogger = require('../core/orchestrator-logger').default;
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
});
OrchestratorSystem.Run.mockResolvedValueOnce('').mockRejectedValueOnce(new Error('check failed'));
await GitHooksService.runHookGroups('/repo', ['pre-commit', 'commit-msg']);
expect(OrchestratorLogger.log).toHaveBeenCalledWith(expect.stringContaining("'pre-commit' passed"));
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining("'commit-msg' failed"));
});
});
describe('configureSkipList', () => {
it('should return empty object for empty skip list', () => {
const result = GitHooksService.configureSkipList([]);
expect(result).toEqual({});
});
it('should return LEFTHOOK_EXCLUDE with comma-separated hooks', () => {
const result = GitHooksService.configureSkipList(['pre-commit', 'pre-push']);
expect(result.LEFTHOOK_EXCLUDE).toBe('pre-commit,pre-push');
});
it('should set HUSKY=0 when hooks are skipped', () => {
const result = GitHooksService.configureSkipList(['pre-commit']);
expect(result.HUSKY).toBe('0');
});
it('should handle single hook in skip list', () => {
const result = GitHooksService.configureSkipList(['commit-msg']);
expect(result.LEFTHOOK_EXCLUDE).toBe('commit-msg');
expect(result.HUSKY).toBe('0');
});
});
describe('disableHooks', () => {
it('should set core.hooksPath to an empty directory', async () => {
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
const { OrchestratorSystem } = require('../core/orchestrator-system');
await GitHooksService.disableHooks('/repo');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
expect.stringContaining('git -C "/repo" config core.hooksPath'),
true,
);
});
it('should log warning on failure to disable hooks', async () => {
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
const { OrchestratorSystem } = require('../core/orchestrator-system');
const OrchestratorLogger = require('../core/orchestrator-logger').default;
OrchestratorSystem.Run.mockRejectedValue(new Error('git config failed'));
await GitHooksService.disableHooks('/repo');
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining('Failed to disable hooks'));
});
});
describe('constants', () => {
it('should have correct package name', () => {
expect(GitHooksService.UNITY_GIT_HOOKS_PACKAGE).toBe('com.frostebite.unitygithooks');
});
});
});

View File

@@ -1,235 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { OrchestratorSystem } from '../core/orchestrator-system';
import OrchestratorLogger from '../core/orchestrator-logger';
export class GitHooksService {
static readonly UNITY_GIT_HOOKS_PACKAGE = 'com.frostebite.unitygithooks';
/**
* Detect which git hook framework is configured in the repository.
* Checks for lefthook and husky configuration files.
*/
static detectHookFramework(repoPath: string): 'lefthook' | 'husky' | 'none' {
// Check for lefthook config files
if (fs.existsSync(path.join(repoPath, 'lefthook.yml')) || fs.existsSync(path.join(repoPath, '.lefthook.yml'))) {
return 'lefthook';
}
// Check for husky directory
if (fs.existsSync(path.join(repoPath, '.husky'))) {
return 'husky';
}
return 'none';
}
/**
* Detect if Unity Git Hooks (com.frostebite.unitygithooks) is installed as a UPM package.
* Checks Packages/manifest.json for the package dependency.
*/
static detectUnityGitHooks(repoPath: string): boolean {
const manifestPath = path.join(repoPath, 'Packages', 'manifest.json');
if (!fs.existsSync(manifestPath)) {
return false;
}
try {
const content = fs.readFileSync(manifestPath, 'utf8');
return content.includes(GitHooksService.UNITY_GIT_HOOKS_PACKAGE);
} catch {
return false;
}
}
/**
* Find the Unity Git Hooks package directory in the Library/PackageCache.
* Returns the path to the package directory, or empty string if not found.
*/
static findUnityGitHooksPackagePath(repoPath: string): string {
const packageCacheDir = path.join(repoPath, 'Library', 'PackageCache');
if (!fs.existsSync(packageCacheDir)) {
return '';
}
try {
const entries = fs.readdirSync(packageCacheDir);
const match = entries.find((entry) => entry.startsWith(GitHooksService.UNITY_GIT_HOOKS_PACKAGE));
if (match) {
return path.join(packageCacheDir, match);
}
} catch {
// PackageCache not available
}
return '';
}
/**
* Initialize Unity Git Hooks by running its init script.
* This installs the required npm modules that the hooks depend on.
* Should be called before installHooks() when Unity Git Hooks is detected.
*/
static async initUnityGitHooks(repoPath: string): Promise<void> {
const packagePath = GitHooksService.findUnityGitHooksPackagePath(repoPath);
if (!packagePath) {
OrchestratorLogger.log(`[GitHooks] Unity Git Hooks package not found in Library/PackageCache, skipping init`);
return;
}
const initScript = path.join(packagePath, '~js', 'init-unity-lefthook.js');
if (!fs.existsSync(initScript)) {
OrchestratorLogger.logWarning(`[GitHooks] Unity Git Hooks init script not found at ${initScript}`);
return;
}
OrchestratorLogger.log(`[GitHooks] Initializing Unity Git Hooks from ${packagePath}`);
try {
await OrchestratorSystem.Run(`cd "${repoPath}" && node "${initScript}"`, true);
OrchestratorLogger.log(`[GitHooks] Unity Git Hooks initialized successfully`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[GitHooks] Unity Git Hooks init failed: ${error.message}`);
}
}
/**
* Configure CI-friendly environment variables for Unity Git Hooks.
* Disables background project mode (CI already has an isolated workspace)
* and sets other env vars appropriate for headless CI environments.
*/
static configureUnityGitHooksCIEnv(): Record<string, string> {
return {
UNITY_GITHOOKS_BACKGROUND_PROJECT_ENABLED: 'false',
CI: 'true',
};
}
/**
* Install git hooks using the detected framework.
* If Unity Git Hooks is detected, initializes it first.
* Errors are caught and logged as warnings - hook installation should not fail the build.
*/
static async installHooks(repoPath: string): Promise<void> {
const framework = GitHooksService.detectHookFramework(repoPath);
if (framework === 'none') {
OrchestratorLogger.log(`[GitHooks] No hook framework detected in ${repoPath}`);
return;
}
OrchestratorLogger.log(`[GitHooks] Detected hook framework: ${framework}`);
// If Unity Git Hooks is present, initialize it before installing hooks
if (framework === 'lefthook' && GitHooksService.detectUnityGitHooks(repoPath)) {
OrchestratorLogger.log(`[GitHooks] Unity Git Hooks (UPM) detected, running init`);
// Set CI-friendly env vars
const ciEnv = GitHooksService.configureUnityGitHooksCIEnv();
for (const [key, value] of Object.entries(ciEnv)) {
process.env[key] = value;
}
await GitHooksService.initUnityGitHooks(repoPath);
}
try {
if (framework === 'lefthook') {
await OrchestratorSystem.Run(`cd "${repoPath}" && npx lefthook install`, true);
OrchestratorLogger.log(`[GitHooks] Lefthook hooks installed`);
} else if (framework === 'husky') {
await OrchestratorSystem.Run(`cd "${repoPath}" && npx husky install`, true);
OrchestratorLogger.log(`[GitHooks] Husky hooks installed`);
}
} catch (error: any) {
OrchestratorLogger.logWarning(`[GitHooks] Hook installation failed: ${error.message}`);
}
}
/**
* Explicitly run specific lefthook hook groups before the build.
* This allows CI to trigger pre-commit, pre-push, or other checks
* that would normally only run on git events.
*
* @param repoPath - Path to the repository
* @param hookGroups - Lefthook group names to run (e.g., ['pre-commit', 'pre-push'])
* @returns Map of group name to success/failure
*/
static async runHookGroups(repoPath: string, hookGroups: string[]): Promise<Record<string, boolean>> {
const results: Record<string, boolean> = {};
if (hookGroups.length === 0) {
return results;
}
const framework = GitHooksService.detectHookFramework(repoPath);
if (framework !== 'lefthook') {
OrchestratorLogger.logWarning(`[GitHooks] runHookGroups requires lefthook, but detected: ${framework}`);
return results;
}
OrchestratorLogger.log(`[GitHooks] Running ${hookGroups.length} hook group(s): ${hookGroups.join(', ')}`);
for (const group of hookGroups) {
try {
await OrchestratorSystem.Run(`cd "${repoPath}" && npx lefthook run ${group}`, true);
OrchestratorLogger.log(`[GitHooks] Hook group '${group}' passed`);
results[group] = true;
} catch (error: any) {
OrchestratorLogger.logWarning(`[GitHooks] Hook group '${group}' failed: ${error.message}`);
results[group] = false;
}
}
return results;
}
/**
* Return environment variables that will skip the listed hooks.
* For lefthook: sets LEFTHOOK_EXCLUDE to a comma-separated list.
* For husky: sets HUSKY=0 to disable all hooks (husky does not support selective skipping).
* The caller is responsible for applying the returned env vars.
*/
static configureSkipList(skipList: string[]): Record<string, string> {
if (skipList.length === 0) {
return {};
}
// Return both lefthook and husky env vars so the caller can apply whichever is relevant.
// Lefthook supports selective hook exclusion.
const env: Record<string, string> = {
LEFTHOOK_EXCLUDE: skipList.join(','),
};
// Husky only supports full disable (HUSKY=0), not selective skipping.
// If any hooks are in the skip list, disable husky entirely.
env.HUSKY = '0';
OrchestratorLogger.log(`[GitHooks] Skip list configured: ${skipList.join(', ')}`);
return env;
}
/**
* Disable all git hooks by pointing core.hooksPath to an empty temporary directory.
* This prevents any hooks from running during the build.
*/
static async disableHooks(repoPath: string): Promise<void> {
try {
const emptyDir = path.join(os.tmpdir(), 'game-ci-empty-hooks');
fs.mkdirSync(emptyDir, { recursive: true });
await OrchestratorSystem.Run(`git -C "${repoPath}" config core.hooksPath "${emptyDir}"`, true);
OrchestratorLogger.log(`[GitHooks] Hooks disabled via core.hooksPath -> ${emptyDir}`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[GitHooks] Failed to disable hooks: ${error.message}`);
}
}
}

View File

@@ -1,395 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import { ElasticGitStorageService } from './elastic-git-storage-service';
jest.mock('node:fs');
jest.mock('node:os');
jest.mock('../core/orchestrator-system', () => ({
OrchestratorSystem: {
Run: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('./lfs-agent-service', () => ({
LfsAgentService: {
configure: jest.fn().mockResolvedValue(undefined),
},
}));
const mockFs = fs as jest.Mocked<typeof fs>;
const mockOs = os as jest.Mocked<typeof os>;
describe('ElasticGitStorageService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('parseAgentValue', () => {
it('should parse name without version', () => {
const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage');
expect(result.name).toBe('elastic-git-storage');
expect(result.version).toBe('latest');
});
it('should parse name@version', () => {
const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage@v1.0.0');
expect(result.name).toBe('elastic-git-storage');
expect(result.version).toBe('v1.0.0');
});
it('should parse name@latest', () => {
const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage@latest');
expect(result.name).toBe('elastic-git-storage');
expect(result.version).toBe('latest');
});
it('should handle trailing @ as latest', () => {
const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage@');
expect(result.name).toBe('elastic-git-storage');
expect(result.version).toBe('latest');
});
it('should handle whitespace', () => {
const result = ElasticGitStorageService.parseAgentValue(' elastic-git-storage@v2.0.0 ');
expect(result.name).toBe('elastic-git-storage');
expect(result.version).toBe('v2.0.0');
});
});
describe('isElasticGitStorage', () => {
it('should match exact name', () => {
expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage')).toBe(true);
});
it('should match with .exe extension', () => {
expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage.exe')).toBe(true);
});
it('should match with @version suffix', () => {
expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage@v1.0.0')).toBe(true);
});
it('should match with @latest suffix', () => {
expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage@latest')).toBe(true);
});
it('should match forward-slash path', () => {
expect(ElasticGitStorageService.isElasticGitStorage('/usr/local/bin/elastic-git-storage')).toBe(true);
});
it('should match backslash path', () => {
expect(ElasticGitStorageService.isElasticGitStorage('C:\\tools\\elastic-git-storage')).toBe(true);
});
it('should match path with .exe', () => {
expect(ElasticGitStorageService.isElasticGitStorage('C:\\tools\\elastic-git-storage.exe')).toBe(true);
});
it('should be case-insensitive', () => {
expect(ElasticGitStorageService.isElasticGitStorage('Elastic-Git-Storage')).toBe(true);
});
it('should handle whitespace', () => {
expect(ElasticGitStorageService.isElasticGitStorage(' elastic-git-storage ')).toBe(true);
});
it('should not match other agent names', () => {
expect(ElasticGitStorageService.isElasticGitStorage('lfs-folderstore')).toBe(false);
});
it('should return false for empty string', () => {
expect(ElasticGitStorageService.isElasticGitStorage('')).toBe(false);
});
it('should not match partial names', () => {
expect(ElasticGitStorageService.isElasticGitStorage('my-elastic-git-storage-v2')).toBe(false);
});
});
describe('findInstalled', () => {
it('should find on PATH via which/where', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
OrchestratorSystem.Run.mockResolvedValue('/usr/local/bin/elastic-git-storage\n');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const result = await ElasticGitStorageService.findInstalled();
expect(result).toBe('/usr/local/bin/elastic-git-storage');
});
it('should use where on windows', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('win32');
OrchestratorSystem.Run.mockResolvedValue('C:\\tools\\elastic-git-storage.exe\n');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const result = await ElasticGitStorageService.findInstalled();
expect(result).toBe('C:\\tools\\elastic-git-storage.exe');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('where'), false, true);
});
it('should check common install locations when not on PATH', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.homedir.mockReturnValue('/home/runner');
OrchestratorSystem.Run.mockRejectedValue(new Error('not found'));
(mockFs.existsSync as jest.Mock)
.mockReturnValueOnce(false) // RUNNER_TOOL_CACHE
.mockReturnValueOnce(true); // /usr/local/bin
const result = await ElasticGitStorageService.findInstalled();
expect(result).toBe('/usr/local/bin/elastic-git-storage');
});
it('should return empty string when not found anywhere', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.homedir.mockReturnValue('/home/runner');
OrchestratorSystem.Run.mockRejectedValue(new Error('not found'));
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = await ElasticGitStorageService.findInstalled();
expect(result).toBe('');
});
it('should check windows-specific locations on win32', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('win32');
mockOs.homedir.mockReturnValue('C:\\Users\\runner');
OrchestratorSystem.Run.mockRejectedValue(new Error('not found'));
const originalEnv = { ...process.env };
process.env.LOCALAPPDATA = 'C:\\Users\\runner\\AppData\\Local';
process.env.RUNNER_TOOL_CACHE = '';
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
return p.includes('AppData');
});
const result = await ElasticGitStorageService.findInstalled();
expect(result).toContain('elastic-git-storage.exe');
process.env = originalEnv;
});
});
describe('install', () => {
it('should download correct binary for linux amd64', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.tmpdir.mockReturnValue('/tmp');
OrchestratorSystem.Run.mockResolvedValue('');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const result = await ElasticGitStorageService.install('latest');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('elastic-git-storage_linux_amd64'));
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('chmod +x'));
expect(result).toContain('elastic-git-storage');
});
it('should download correct binary for darwin arm64', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('darwin');
mockOs.arch.mockReturnValue('arm64');
mockOs.tmpdir.mockReturnValue('/tmp');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
await ElasticGitStorageService.install('v1.2.0');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('elastic-git-storage_darwin_arm64'));
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('v1.2.0'));
});
it('should download .exe for windows', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('win32');
mockOs.arch.mockReturnValue('x64');
mockOs.tmpdir.mockReturnValue('C:\\temp');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
await ElasticGitStorageService.install('latest');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
expect.stringContaining('elastic-git-storage_windows_amd64.exe'),
);
// Should NOT chmod on windows
expect(OrchestratorSystem.Run).not.toHaveBeenCalledWith(expect.stringContaining('chmod'));
});
it('should use RUNNER_TOOL_CACHE for install dir when available', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
OrchestratorSystem.Run.mockResolvedValue('');
const originalCache = process.env.RUNNER_TOOL_CACHE;
process.env.RUNNER_TOOL_CACHE = '/opt/hostedtoolcache';
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const result = await ElasticGitStorageService.install('latest');
expect(result).toContain('hostedtoolcache');
if (originalCache === undefined) {
delete process.env.RUNNER_TOOL_CACHE;
} else {
process.env.RUNNER_TOOL_CACHE = originalCache;
}
});
it('should use latest release URL when version is latest', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.tmpdir.mockReturnValue('/tmp');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
await ElasticGitStorageService.install('latest');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('/releases/latest/download/'));
});
it('should use tagged release URL when version is specified', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.tmpdir.mockReturnValue('/tmp');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
await ElasticGitStorageService.install('v2.0.0');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('/releases/download/v2.0.0/'));
});
it('should return empty string on download failure', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.tmpdir.mockReturnValue('/tmp');
OrchestratorSystem.Run.mockRejectedValue(new Error('curl failed'));
const result = await ElasticGitStorageService.install('latest');
expect(result).toBe('');
});
it('should return empty string if binary not found after download', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.tmpdir.mockReturnValue('/tmp');
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = await ElasticGitStorageService.install('latest');
expect(result).toBe('');
});
});
describe('ensureAndConfigure', () => {
it('should use existing installation if found', async () => {
const { LfsAgentService } = require('./lfs-agent-service');
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.homedir.mockReturnValue('/home/runner');
// findInstalled returns a result
OrchestratorSystem.Run.mockResolvedValue('/usr/local/bin/elastic-git-storage\n');
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const result = await ElasticGitStorageService.ensureAndConfigure('latest', '--verbose', ['/mnt/lfs'], '/repo');
expect(result).toBe('/usr/local/bin/elastic-git-storage');
expect(LfsAgentService.configure).toHaveBeenCalledWith(
'/usr/local/bin/elastic-git-storage',
'--verbose',
['/mnt/lfs'],
'/repo',
);
});
it('should install when not found and configure', async () => {
const { LfsAgentService } = require('./lfs-agent-service');
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.homedir.mockReturnValue('/home/runner');
mockOs.tmpdir.mockReturnValue('/tmp');
// findInstalled finds nothing
OrchestratorSystem.Run.mockRejectedValueOnce(new Error('not found')) // which
.mockResolvedValueOnce('') // curl download
.mockResolvedValueOnce(''); // chmod
(mockFs.existsSync as jest.Mock)
.mockReturnValueOnce(false) // RUNNER_TOOL_CACHE
.mockReturnValueOnce(false) // /usr/local/bin
.mockReturnValueOnce(false) // ~/.local/bin
.mockReturnValueOnce(true); // after install
const result = await ElasticGitStorageService.ensureAndConfigure('v1.0.0', '', [], '/repo');
expect(result).toContain('elastic-git-storage');
expect(LfsAgentService.configure).toHaveBeenCalled();
});
it('should return empty string when install fails', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.homedir.mockReturnValue('/home/runner');
mockOs.tmpdir.mockReturnValue('/tmp');
// findInstalled finds nothing
OrchestratorSystem.Run.mockRejectedValue(new Error('not found'));
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = await ElasticGitStorageService.ensureAndConfigure('latest', '', [], '/repo');
expect(result).toBe('');
});
it('should use default version when empty string passed', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
mockOs.platform.mockReturnValue('linux');
mockOs.arch.mockReturnValue('x64');
mockOs.homedir.mockReturnValue('/home/runner');
mockOs.tmpdir.mockReturnValue('/tmp');
// findInstalled finds nothing
OrchestratorSystem.Run.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce('')
.mockResolvedValueOnce('');
(mockFs.existsSync as jest.Mock)
.mockReturnValueOnce(false)
.mockReturnValueOnce(false)
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
await ElasticGitStorageService.ensureAndConfigure('', '', [], '/repo');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('/releases/latest/download/'));
});
});
describe('constants', () => {
it('should have correct repo owner', () => {
expect(ElasticGitStorageService.REPO_OWNER).toBe('frostebite');
});
it('should have correct repo name', () => {
expect(ElasticGitStorageService.REPO_NAME).toBe('elastic-git-storage');
});
it('should have correct agent name', () => {
expect(ElasticGitStorageService.AGENT_NAME).toBe('elastic-git-storage');
});
});
});

View File

@@ -1,211 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { OrchestratorSystem } from '../core/orchestrator-system';
import OrchestratorLogger from '../core/orchestrator-logger';
import { LfsAgentService } from './lfs-agent-service';
/**
* Built-in support for elastic-git-storage, a custom Git LFS transfer agent
* that supports multiple storage backends (local filesystem, WebDAV, rclone remotes).
*
* When `lfsTransferAgent` is set to 'elastic-git-storage', this service:
* 1. Checks if the agent is already installed on the system
* 2. If not, downloads it from the GitHub release page
* 3. Makes it executable
* 4. Delegates to LfsAgentService for git config setup
*
* @see https://github.com/frostebite/elastic-git-storage
*/
export class ElasticGitStorageService {
static readonly REPO_OWNER = 'frostebite';
static readonly REPO_NAME = 'elastic-git-storage';
static readonly AGENT_NAME = 'elastic-git-storage';
/**
* Parse an agent value that may include a version suffix.
* Supports formats like:
* - 'elastic-git-storage' → { name: 'elastic-git-storage', version: 'latest' }
* - 'elastic-git-storage@v1.0.0' → { name: 'elastic-git-storage', version: 'v1.0.0' }
* - 'elastic-git-storage@latest' → { name: 'elastic-git-storage', version: 'latest' }
*/
static parseAgentValue(agentValue: string): { name: string; version: string } {
const trimmed = agentValue.trim();
const atIndex = trimmed.indexOf('@');
if (atIndex > 0) {
return {
name: trimmed.substring(0, atIndex),
version: trimmed.substring(atIndex + 1) || 'latest',
};
}
return { name: trimmed, version: 'latest' };
}
/**
* Check if the given lfsTransferAgent value refers to elastic-git-storage.
* Matches the exact name (without path) or a path ending in the agent name.
* Also matches 'elastic-git-storage@version' format.
*/
static isElasticGitStorage(agentValue: string): boolean {
if (!agentValue) return false;
const { name } = ElasticGitStorageService.parseAgentValue(agentValue);
const normalized = name.trim().toLowerCase();
return (
normalized === 'elastic-git-storage' ||
normalized === 'elastic-git-storage.exe' ||
normalized.endsWith('/elastic-git-storage') ||
normalized.endsWith('\\elastic-git-storage') ||
normalized.endsWith('/elastic-git-storage.exe') ||
normalized.endsWith('\\elastic-git-storage.exe')
);
}
/**
* Resolve the full path to the elastic-git-storage executable.
*
* Search order:
* 1. The provided path (if it's a full path and exists)
* 2. $PATH lookup via `which` / `where`
* 3. Known install locations ($RUNNER_TOOL_CACHE, /usr/local/bin, ~/.local/bin)
*
* @returns Full path to the executable, or empty string if not found
*/
static async findInstalled(): Promise<string> {
// Check PATH
try {
const whichCmd = os.platform() === 'win32' ? 'where elastic-git-storage' : 'which elastic-git-storage';
const result = await OrchestratorSystem.Run(whichCmd, false, true);
const foundPath = result.trim().split('\n')[0].trim();
if (foundPath && fs.existsSync(foundPath)) {
return foundPath;
}
} catch {
// Not on PATH
}
// Check common install locations
const candidates = [
path.join(process.env.RUNNER_TOOL_CACHE || '', 'elastic-git-storage', 'elastic-git-storage'),
'/usr/local/bin/elastic-git-storage',
path.join(os.homedir(), '.local', 'bin', 'elastic-git-storage'),
];
if (os.platform() === 'win32') {
candidates.push(
path.join(process.env.RUNNER_TOOL_CACHE || '', 'elastic-git-storage', 'elastic-git-storage.exe'),
path.join(process.env.LOCALAPPDATA || '', 'elastic-git-storage', 'elastic-git-storage.exe'),
);
}
for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) {
return candidate;
}
}
return '';
}
/**
* Install elastic-git-storage from GitHub releases.
*
* @param version - Version to install (e.g., 'v1.0.0', 'latest')
* @returns Path to the installed executable
*/
static async install(version: string = 'latest'): Promise<string> {
const platform = os.platform();
const arch = os.arch();
const osName = platform === 'win32' ? 'windows' : platform === 'darwin' ? 'darwin' : 'linux';
const archName = arch === 'x64' ? 'amd64' : arch === 'arm64' ? 'arm64' : 'amd64';
const ext = platform === 'win32' ? '.exe' : '';
const installDir = process.env.RUNNER_TOOL_CACHE
? path.join(process.env.RUNNER_TOOL_CACHE, 'elastic-git-storage')
: path.join(os.tmpdir(), 'elastic-git-storage');
const binaryName = `elastic-git-storage${ext}`;
const installPath = path.join(installDir, binaryName);
OrchestratorLogger.log(`[ElasticGitStorage] Installing to ${installPath}`);
// Create install directory
fs.mkdirSync(installDir, { recursive: true });
// Build download URL
const releaseTag = version === 'latest' ? 'latest' : version;
const assetName = `elastic-git-storage_${osName}_${archName}${ext}`;
let downloadUrl: string;
if (releaseTag === 'latest') {
downloadUrl = `https://github.com/${ElasticGitStorageService.REPO_OWNER}/${ElasticGitStorageService.REPO_NAME}/releases/latest/download/${assetName}`;
} else {
downloadUrl = `https://github.com/${ElasticGitStorageService.REPO_OWNER}/${ElasticGitStorageService.REPO_NAME}/releases/download/${releaseTag}/${assetName}`;
}
OrchestratorLogger.log(`[ElasticGitStorage] Downloading from ${downloadUrl}`);
try {
await OrchestratorSystem.Run(`curl -fsSL -o "${installPath}" "${downloadUrl}"`);
if (platform !== 'win32') {
await OrchestratorSystem.Run(`chmod +x "${installPath}"`);
}
// Verify installation
if (!fs.existsSync(installPath)) {
throw new Error(`Binary not found after download at ${installPath}`);
}
OrchestratorLogger.log(`[ElasticGitStorage] Successfully installed ${releaseTag} to ${installPath}`);
return installPath;
} catch (error: any) {
OrchestratorLogger.logWarning(
`[ElasticGitStorage] Failed to install: ${error.message}. Continuing without elastic-git-storage.`,
);
return '';
}
}
/**
* Ensure elastic-git-storage is available (find or install) and configure it.
*
* @param version - Version to install if not found ('latest' or a tag like 'v1.0.0')
* @param agentArgs - Additional arguments to pass to the agent
* @param storagePaths - Storage paths for the agent
* @param repoPath - Path to the git repository
* @returns Path to the configured executable, or empty string if setup failed
*/
static async ensureAndConfigure(
version: string,
agentArgs: string,
storagePaths: string[],
repoPath: string,
): Promise<string> {
OrchestratorLogger.log(`[ElasticGitStorage] Setting up elastic-git-storage (version: ${version || 'latest'})`);
// Try to find existing installation
let agentPath = await ElasticGitStorageService.findInstalled();
if (agentPath) {
OrchestratorLogger.log(`[ElasticGitStorage] Found existing installation at ${agentPath}`);
} else {
OrchestratorLogger.log(`[ElasticGitStorage] Not found on system, installing...`);
agentPath = await ElasticGitStorageService.install(version || 'latest');
if (!agentPath) {
return '';
}
}
// Delegate to LfsAgentService for git config setup
await LfsAgentService.configure(agentPath, agentArgs, storagePaths, repoPath);
return agentPath;
}
}

View File

@@ -1,122 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { LfsAgentService } from './lfs-agent-service';
// Mock dependencies
jest.mock('node:fs');
jest.mock('../core/orchestrator-system', () => ({
OrchestratorSystem: {
Run: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
error: jest.fn(),
},
}));
const mockFs = fs as jest.Mocked<typeof fs>;
describe('LfsAgentService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('configure', () => {
it('should call correct git config commands when agent exists', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const { OrchestratorSystem } = require('../core/orchestrator-system');
await LfsAgentService.configure(
'/usr/local/bin/elastic-git-storage',
'--verbose',
['/storage/path1', '/storage/path2'],
'/repo',
);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
`git -C "/repo" config lfs.customtransfer.elastic-git-storage.path "/usr/local/bin/elastic-git-storage"`,
);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
`git -C "/repo" config lfs.customtransfer.elastic-git-storage.args "--verbose"`,
);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
`git -C "/repo" config lfs.standalonetransferagent elastic-git-storage`,
);
});
it('should set LFS_STORAGE_PATHS environment variable when storagePaths provided', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
await LfsAgentService.configure('/usr/local/bin/elastic-git-storage', '', ['/path/a', '/path/b'], '/repo');
expect(process.env.LFS_STORAGE_PATHS).toBe('/path/a;/path/b');
});
it('should log warning and return early when agent executable does not exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const { OrchestratorSystem } = require('../core/orchestrator-system');
await LfsAgentService.configure('/nonexistent/agent', '', [], '/repo');
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
});
it('should derive agent name from executable filename', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const { OrchestratorSystem } = require('../core/orchestrator-system');
await LfsAgentService.configure('/tools/my-custom-agent.exe', '', [], '/repo');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
`git -C "/repo" config lfs.customtransfer.my-custom-agent.path "/tools/my-custom-agent.exe"`,
);
});
});
describe('configure with empty storagePaths', () => {
it('should not set LFS_STORAGE_PATHS when storagePaths is empty', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const originalValue = process.env.LFS_STORAGE_PATHS;
delete process.env.LFS_STORAGE_PATHS;
await LfsAgentService.configure('/usr/local/bin/agent', '', [], '/repo');
expect(process.env.LFS_STORAGE_PATHS).toBeUndefined();
if (originalValue !== undefined) {
process.env.LFS_STORAGE_PATHS = originalValue;
}
});
});
describe('validate', () => {
it('should return true when agent executable exists', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
const result = await LfsAgentService.validate('/usr/local/bin/elastic-git-storage');
expect(result).toBe(true);
});
it('should return false when agent executable does not exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = await LfsAgentService.validate('/nonexistent/agent');
expect(result).toBe(false);
});
it('should log warning when agent does not exist', async () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const OrchestratorLogger = require('../core/orchestrator-logger').default;
await LfsAgentService.validate('/nonexistent/agent');
expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith(expect.stringContaining('Agent executable not found'));
});
});
});

View File

@@ -1,59 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { OrchestratorSystem } from '../core/orchestrator-system';
import OrchestratorLogger from '../core/orchestrator-logger';
export class LfsAgentService {
/**
* Configure a custom LFS transfer agent in a git repository.
* Sets up the git config entries and environment variables needed for the agent.
*/
static async configure(
agentPath: string,
agentArgs: string,
storagePaths: string[],
repoPath: string,
): Promise<void> {
// Validate the agent executable exists
if (!fs.existsSync(agentPath)) {
OrchestratorLogger.logWarning(
`[LfsAgent] Agent executable not found at ${agentPath}, continuing without custom LFS agent`,
);
return;
}
// Derive agent name from executable filename (without extension)
const agentName = path.basename(agentPath, path.extname(agentPath));
OrchestratorLogger.log(`[LfsAgent] Configuring custom LFS transfer agent: ${agentName}`);
OrchestratorLogger.log(`[LfsAgent] Path: ${agentPath}`);
OrchestratorLogger.log(`[LfsAgent] Args: ${agentArgs}`);
// Set git config entries for the custom transfer agent
await OrchestratorSystem.Run(`git -C "${repoPath}" config lfs.customtransfer.${agentName}.path "${agentPath}"`);
await OrchestratorSystem.Run(`git -C "${repoPath}" config lfs.customtransfer.${agentName}.args "${agentArgs}"`);
await OrchestratorSystem.Run(`git -C "${repoPath}" config lfs.standalonetransferagent ${agentName}`);
// Set storage paths environment variable if provided
if (storagePaths.length > 0) {
const storagePathsValue = storagePaths.join(';');
process.env.LFS_STORAGE_PATHS = storagePathsValue;
OrchestratorLogger.log(`[LfsAgent] Storage paths: ${storagePathsValue}`);
}
OrchestratorLogger.log(`[LfsAgent] Custom LFS transfer agent configured successfully`);
}
/**
* Validate that the LFS transfer agent executable exists.
*/
static async validate(agentPath: string): Promise<boolean> {
const exists = fs.existsSync(agentPath);
if (!exists) {
OrchestratorLogger.logWarning(`[LfsAgent] Agent executable not found: ${agentPath}`);
}
return exists;
}
}

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

@@ -1,446 +0,0 @@
import fs from 'node:fs';
import * as core from '@actions/core';
import { SecretSourceService, validateSecretKey } from './secret-source-service';
jest.mock('node:fs');
jest.mock('@actions/core', () => ({
setSecret: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
}));
jest.mock('../core/orchestrator-system', () => ({
OrchestratorSystem: {
Run: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
error: jest.fn(),
},
}));
const mockFs = fs as jest.Mocked<typeof fs>;
describe('SecretSourceService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('validateSecretKey', () => {
it('should accept alphanumeric keys', () => {
expect(validateSecretKey('MY_SECRET_KEY')).toBe('MY_SECRET_KEY');
});
it('should accept keys with hyphens', () => {
expect(validateSecretKey('my-secret-key')).toBe('my-secret-key');
});
it('should accept keys with dots', () => {
expect(validateSecretKey('my.secret.key')).toBe('my.secret.key');
});
it('should accept keys with forward slashes', () => {
expect(validateSecretKey('path/to/secret')).toBe('path/to/secret');
});
it('should accept keys with mixed valid characters', () => {
expect(validateSecretKey('my-app/prod_db.password')).toBe('my-app/prod_db.password');
});
it('should reject keys with semicolons (shell injection)', () => {
expect(() => validateSecretKey('; rm -rf /')).toThrow('Invalid secret key name');
});
it('should reject keys with backticks (command substitution)', () => {
expect(() => validateSecretKey('`whoami`')).toThrow('Invalid secret key name');
});
it('should reject keys with dollar signs (variable expansion)', () => {
expect(() => validateSecretKey('$HOME')).toThrow('Invalid secret key name');
});
it('should reject keys with pipe characters', () => {
expect(() => validateSecretKey('key | cat /etc/passwd')).toThrow('Invalid secret key name');
});
it('should reject keys with ampersands', () => {
expect(() => validateSecretKey('key && echo pwned')).toThrow('Invalid secret key name');
});
it('should reject keys with newlines', () => {
expect(() => validateSecretKey('key\nmalicious')).toThrow('Invalid secret key name');
});
it('should reject keys with quotes', () => {
expect(() => validateSecretKey('"key"')).toThrow('Invalid secret key name');
expect(() => validateSecretKey("'key'")).toThrow('Invalid secret key name');
});
it('should reject keys with parentheses (subshell)', () => {
expect(() => validateSecretKey('$(whoami)')).toThrow('Invalid secret key name');
});
it('should reject empty keys', () => {
expect(() => validateSecretKey('')).toThrow('Invalid secret key name');
});
it('should reject keys with spaces', () => {
expect(() => validateSecretKey('key with spaces')).toThrow('Invalid secret key name');
});
});
describe('isPremadeSource', () => {
it('should return true for aws-secrets-manager', () => {
expect(SecretSourceService.isPremadeSource('aws-secrets-manager')).toBe(true);
});
it('should return true for aws-secret-manager (legacy alias)', () => {
expect(SecretSourceService.isPremadeSource('aws-secret-manager')).toBe(true);
});
it('should return true for aws-parameter-store', () => {
expect(SecretSourceService.isPremadeSource('aws-parameter-store')).toBe(true);
});
it('should return true for gcp-secret-manager', () => {
expect(SecretSourceService.isPremadeSource('gcp-secret-manager')).toBe(true);
});
it('should return true for azure-key-vault', () => {
expect(SecretSourceService.isPremadeSource('azure-key-vault')).toBe(true);
});
it('should return true for hashicorp-vault', () => {
expect(SecretSourceService.isPremadeSource('hashicorp-vault')).toBe(true);
});
it('should return true for hashicorp-vault-kv1', () => {
expect(SecretSourceService.isPremadeSource('hashicorp-vault-kv1')).toBe(true);
});
it('should return true for vault (short alias)', () => {
expect(SecretSourceService.isPremadeSource('vault')).toBe(true);
});
it('should return false for unknown source', () => {
expect(SecretSourceService.isPremadeSource('unknown-source')).toBe(false);
});
});
describe('getAvailableSources', () => {
it('should return all premade source names', () => {
const sources = SecretSourceService.getAvailableSources();
expect(sources).toContain('aws-secrets-manager');
expect(sources).toContain('aws-parameter-store');
expect(sources).toContain('gcp-secret-manager');
expect(sources).toContain('azure-key-vault');
expect(sources).toContain('hashicorp-vault');
expect(sources).toContain('hashicorp-vault-kv1');
expect(sources).toContain('vault');
expect(sources.length).toBeGreaterThanOrEqual(8);
});
});
describe('resolveSource', () => {
it('should resolve premade source by name', () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager');
expect(source).toBeDefined();
expect(source!.name).toBe('aws-secrets-manager');
expect(source!.command).toContain('secretsmanager');
});
it('should resolve custom command with {0} placeholder', () => {
const source = SecretSourceService.resolveSource('vault kv get -field=value secret/{0}');
expect(source).toBeDefined();
expect(source!.name).toBe('custom-command');
expect(source!.command).toContain('{0}');
});
it('should resolve command with spaces as custom command', () => {
const source = SecretSourceService.resolveSource('my-tool get-secret');
expect(source).toBeDefined();
expect(source!.name).toBe('custom-command');
});
it('should return undefined for unknown single-word source', () => {
const source = SecretSourceService.resolveSource('unknown');
expect(source).toBeUndefined();
});
});
describe('fetchSecret', () => {
it('should run the command with {0} replaced by key', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('my-secret-value');
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
const result = await SecretSourceService.fetchSecret(source, 'MY_SECRET');
expect(result).toBe('my-secret-value');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('MY_SECRET'), false, true);
});
it('should parse JSON output when parseOutput is json-field', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue(JSON.stringify({ value: 'extracted-secret' }));
const source = {
name: 'test-source',
command: 'fetch {0}',
parseOutput: 'json-field' as const,
jsonField: 'value',
};
const result = await SecretSourceService.fetchSecret(source, 'KEY');
expect(result).toBe('extracted-secret');
});
it('should fall back to raw output on invalid JSON with json-field mode', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('not-json');
const source = {
name: 'test-source',
command: 'fetch {0}',
parseOutput: 'json-field' as const,
jsonField: 'value',
};
const result = await SecretSourceService.fetchSecret(source, 'KEY');
expect(result).toBe('not-json');
});
it('should return empty string on command failure', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockRejectedValue(new Error('command not found'));
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
const result = await SecretSourceService.fetchSecret(source, 'KEY');
expect(result).toBe('');
});
it('should reject keys with shell injection characters', async () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await expect(SecretSourceService.fetchSecret(source, '; rm -rf /')).rejects.toThrow('Invalid secret key name');
});
it('should reject keys with command substitution', async () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await expect(SecretSourceService.fetchSecret(source, '$(whoami)')).rejects.toThrow('Invalid secret key name');
});
it('should reject keys with backtick command substitution', async () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await expect(SecretSourceService.fetchSecret(source, '`cat /etc/passwd`')).rejects.toThrow(
'Invalid secret key name',
);
});
it('should accept keys with valid path-like patterns', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('secret-value');
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
const result = await SecretSourceService.fetchSecret(source, 'prod/database/password');
expect(result).toBe('secret-value');
});
it('should mask fetched secret values with core.setSecret', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('super-secret-value');
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await SecretSourceService.fetchSecret(source, 'MY_SECRET');
expect(core.setSecret).toHaveBeenCalledWith('super-secret-value');
});
it('should not mask empty secret values', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('');
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await SecretSourceService.fetchSecret(source, 'MY_SECRET');
expect(core.setSecret).not.toHaveBeenCalled();
});
it('should mask JSON-extracted secret values', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue(JSON.stringify({ value: 'json-secret' }));
const source = {
name: 'test-source',
command: 'fetch {0}',
parseOutput: 'json-field' as const,
jsonField: 'value',
};
await SecretSourceService.fetchSecret(source, 'KEY');
expect(core.setSecret).toHaveBeenCalledWith('json-secret');
});
});
describe('fetchFromEnv', () => {
it('should return env var value when set', () => {
process.env.TEST_SECRET_KEY = 'env-value';
const result = SecretSourceService.fetchFromEnv('TEST_SECRET_KEY');
expect(result).toBe('env-value');
delete process.env.TEST_SECRET_KEY;
});
it('should return empty string when env var is not set', () => {
const result = SecretSourceService.fetchFromEnv('NONEXISTENT_KEY_12345');
expect(result).toBe('');
});
it('should mask env var values with core.setSecret', () => {
process.env.TEST_MASK_KEY = 'masked-env-value';
SecretSourceService.fetchFromEnv('TEST_MASK_KEY');
expect(core.setSecret).toHaveBeenCalledWith('masked-env-value');
delete process.env.TEST_MASK_KEY;
});
it('should not mask empty env var values', () => {
const result = SecretSourceService.fetchFromEnv('NONEXISTENT_KEY_99999');
expect(result).toBe('');
expect(core.setSecret).not.toHaveBeenCalled();
});
});
describe('fetchAll', () => {
it('should fetch all keys from env source', async () => {
process.env.KEY_A = 'val-a';
process.env.KEY_B = 'val-b';
const results = await SecretSourceService.fetchAll('env', ['KEY_A', 'KEY_B']);
expect(results.KEY_A).toBe('val-a');
expect(results.KEY_B).toBe('val-b');
delete process.env.KEY_A;
delete process.env.KEY_B;
});
it('should fetch all keys from premade source', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValueOnce('secret-1').mockResolvedValueOnce('secret-2');
const results = await SecretSourceService.fetchAll('aws-parameter-store', ['param1', 'param2']);
expect(results.param1).toBe('secret-1');
expect(results.param2).toBe('secret-2');
expect(OrchestratorSystem.Run).toHaveBeenCalledTimes(2);
});
it('should return empty results for unknown source', async () => {
const results = await SecretSourceService.fetchAll('unknown', ['key1']);
expect(results).toEqual({});
});
});
describe('loadFromYaml', () => {
it('should return empty array when file does not exist', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = SecretSourceService.loadFromYaml('/nonexistent.yml');
expect(result).toEqual([]);
});
it('should parse valid YAML source definitions', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(`
sources:
- name: my-vault
command: 'vault kv get -field=value secret/{0}'
- name: my-api
command: 'curl -s https://api.example.com/{0}'
parseOutput: json-field
jsonField: secret_value
`);
const result = SecretSourceService.loadFromYaml('/sources.yml');
expect(result).toHaveLength(2);
expect(result[0].name).toBe('my-vault');
expect(result[0].command).toBe('vault kv get -field=value secret/{0}');
expect(result[1].name).toBe('my-api');
expect(result[1].parseOutput).toBe('json-field');
expect(result[1].jsonField).toBe('secret_value');
});
it('should handle YAML with single source', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(`
sources:
- name: simple
command: echo {0}
`);
const result = SecretSourceService.loadFromYaml('/simple.yml');
expect(result).toHaveLength(1);
expect(result[0].name).toBe('simple');
});
it('should return empty array on parse error', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockImplementation(() => {
throw new Error('Permission denied');
});
const result = SecretSourceService.loadFromYaml('/error.yml');
expect(result).toEqual([]);
});
});
describe('premade source commands', () => {
it('aws-secrets-manager uses --query SecretString', () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
expect(source.command).toContain('--query SecretString');
expect(source.command).toContain('--output text');
});
it('aws-parameter-store uses --with-decryption', () => {
const source = SecretSourceService.resolveSource('aws-parameter-store')!;
expect(source.command).toContain('--with-decryption');
expect(source.command).toContain('--query Parameter.Value');
});
it('gcp-secret-manager uses latest version', () => {
const source = SecretSourceService.resolveSource('gcp-secret-manager')!;
expect(source.command).toContain('latest');
});
it('azure-key-vault uses AZURE_VAULT_NAME env var', () => {
const source = SecretSourceService.resolveSource('azure-key-vault')!;
expect(source.command).toContain('$AZURE_VAULT_NAME');
});
it('hashicorp-vault uses vault kv get with VAULT_MOUNT', () => {
const source = SecretSourceService.resolveSource('hashicorp-vault')!;
expect(source.command).toContain('vault kv get');
expect(source.command).toContain('VAULT_MOUNT');
expect(source.command).toContain('-field=value');
});
it('hashicorp-vault-kv1 uses vault read for KV v1', () => {
const source = SecretSourceService.resolveSource('hashicorp-vault-kv1')!;
expect(source.command).toContain('vault read');
expect(source.command).toContain('-field=value');
});
it('vault alias resolves to same command as hashicorp-vault', () => {
const vault = SecretSourceService.resolveSource('vault')!;
const hashicorpVault = SecretSourceService.resolveSource('hashicorp-vault')!;
expect(vault.command).toBe(hashicorpVault.command);
});
});
});

View File

@@ -1,337 +0,0 @@
import fs from 'node:fs';
import * as core from '@actions/core';
import OrchestratorLogger from '../core/orchestrator-logger';
import { OrchestratorSystem } from '../core/orchestrator-system';
/**
* A secret source definition: how to fetch a secret value by key.
*/
export interface SecretSourceDefinition {
name: string;
command: string;
parseOutput?: 'raw' | 'json-field';
jsonField?: string;
}
/**
* Validate that a secret key name contains only safe characters.
* Prevents shell injection when keys are interpolated into commands.
*
* Allowed characters: alphanumeric, hyphens, underscores, dots, forward slashes.
*
* @param key - The secret key name to validate
* @returns The validated key (unchanged)
* @throws Error if the key contains disallowed characters
*/
export function validateSecretKey(key: string): string {
if (!/^[a-zA-Z0-9\-_./]+$/.test(key)) {
throw new Error(
`Invalid secret key name: "${key}". Keys may only contain alphanumeric characters, hyphens, underscores, dots, and forward slashes.`,
);
}
return key;
}
/**
* Mask a secret value so it does not appear in GitHub Actions logs.
* Empty or whitespace-only values are skipped (core.setSecret would be a no-op).
*/
function maskSecretValue(value: string): void {
if (value.trim().length > 0) {
core.setSecret(value);
}
}
/**
* Premade secret sources and custom YAML-based secret source definitions.
*
* Premade sources are string shortcuts that expand to shell commands:
* - `aws-secrets-manager` -- AWS Secrets Manager
* - `aws-parameter-store` -- AWS Systems Manager Parameter Store
* - `gcp-secret-manager` -- Google Cloud Secret Manager
* - `azure-key-vault` -- Azure Key Vault (requires AZURE_VAULT_NAME env var)
* - `hashicorp-vault` -- HashiCorp Vault KV v2 (requires VAULT_ADDR, optionally VAULT_MOUNT)
* - `hashicorp-vault-kv1` -- HashiCorp Vault KV v1 (requires VAULT_ADDR, optionally VAULT_MOUNT)
* - `env` -- Read from environment variables (no shell command needed)
*
* Custom YAML format:
* sources:
* - name: my-vault
* command: 'vault kv get -field=value secret/{0}'
* - name: my-api
* command: 'curl -s https://secrets.example.com/api/{0}'
* parseOutput: json-field
* jsonField: value
*/
export class SecretSourceService {
private static readonly premadeSources: Record<string, SecretSourceDefinition> = {
'aws-secrets-manager': {
name: 'aws-secrets-manager',
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
parseOutput: 'raw',
},
'aws-secret-manager': {
// Alias for backward compatibility (original name in inputPullCommand)
name: 'aws-secret-manager',
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
parseOutput: 'raw',
},
'aws-parameter-store': {
name: 'aws-parameter-store',
command: 'aws ssm get-parameter --name {0} --with-decryption --query Parameter.Value --output text',
parseOutput: 'raw',
},
'gcp-secret-manager': {
name: 'gcp-secret-manager',
command: 'gcloud secrets versions access latest --secret="{0}"',
parseOutput: 'raw',
},
'azure-key-vault': {
name: 'azure-key-vault',
command: 'az keyvault secret show --vault-name "$AZURE_VAULT_NAME" --name {0} --query value --output tsv',
parseOutput: 'raw',
},
'hashicorp-vault': {
// HashiCorp Vault KV v2 (default). Requires VAULT_ADDR env var.
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
// Authentication is handled by VAULT_TOKEN or other Vault auth env vars.
name: 'hashicorp-vault',
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
parseOutput: 'raw',
},
'hashicorp-vault-kv1': {
// HashiCorp Vault KV v1. Requires VAULT_ADDR env var.
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
name: 'hashicorp-vault-kv1',
command: 'vault read -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
parseOutput: 'raw',
},
vault: {
// Short alias for hashicorp-vault (KV v2)
name: 'vault',
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
parseOutput: 'raw',
},
};
/**
* Check if a source name is a known premade source.
*/
static isPremadeSource(sourceName: string): boolean {
return sourceName in SecretSourceService.premadeSources;
}
/**
* Get the list of available premade source names.
*/
static getAvailableSources(): string[] {
return Object.keys(SecretSourceService.premadeSources);
}
/**
* Resolve a source name to a SecretSourceDefinition.
*
* - If the name matches a premade source, returns that definition.
* - If it looks like a shell command (contains spaces or {0}), wraps it as a custom command.
* - Otherwise, returns undefined.
*/
static resolveSource(sourceName: string): SecretSourceDefinition | undefined {
// Check premade sources
if (SecretSourceService.isPremadeSource(sourceName)) {
return SecretSourceService.premadeSources[sourceName];
}
// If it contains a placeholder or spaces, treat it as a raw command
if (sourceName.includes('{0}') || sourceName.includes(' ')) {
return {
name: 'custom-command',
command: sourceName,
parseOutput: 'raw',
};
}
return undefined;
}
/**
* Load custom secret source definitions from a YAML file.
*
* Expected format:
* sources:
* - name: my-source
* command: 'my-tool get-secret {0}'
* - name: my-api
* command: 'curl -s https://api.example.com/secrets/{0}'
* parseOutput: json-field
* jsonField: value
*/
static loadFromYaml(filePath: string): SecretSourceDefinition[] {
if (!fs.existsSync(filePath)) {
OrchestratorLogger.logWarning(`Secret source YAML not found: ${filePath}`);
return [];
}
try {
const content = fs.readFileSync(filePath, 'utf8');
const parsed = SecretSourceService.parseSimpleYaml(content);
return parsed;
} catch (error: any) {
OrchestratorLogger.logWarning(`Failed to parse secret source YAML: ${error.message}`);
return [];
}
}
/**
* Fetch a secret value using the given source definition.
*
* Validates the key against an allowlist pattern before interpolating it
* into the command string to prevent shell injection. The fetched secret
* value is masked via core.setSecret() so it does not leak in logs.
*
* @param source - The secret source definition to use
* @param key - The secret key to fetch
* @returns The secret value, or empty string on failure
*/
static async fetchSecret(source: SecretSourceDefinition, key: string): Promise<string> {
// Validate the key to prevent shell injection
validateSecretKey(key);
const command = source.command.replace(/\{0\}/g, key);
try {
const output = await OrchestratorSystem.Run(command, false, true);
let value: string;
if (source.parseOutput === 'json-field' && source.jsonField) {
try {
const parsed = JSON.parse(output);
value = parsed[source.jsonField] || '';
} catch {
OrchestratorLogger.logWarning(`Failed to parse JSON output from ${source.name} for key ${key}`);
value = output.trim();
}
} else {
value = output.trim();
}
// Mask the secret value so it does not appear in GitHub Actions logs
maskSecretValue(value);
return value;
} catch (error: any) {
OrchestratorLogger.logWarning(`Failed to fetch secret '${key}' from ${source.name}: ${error.message}`);
return '';
}
}
/**
* Fetch a secret from an environment variable. No shell command needed.
* The value is masked via core.setSecret() so it does not leak in logs.
*/
static fetchFromEnv(key: string): string {
const value = process.env[key] || '';
maskSecretValue(value);
return value;
}
/**
* Resolve a source name and fetch all secrets from it.
*
* @param sourceName - Premade source name, shell command, or 'env'
* @param keys - List of secret keys to fetch
* @returns Map of key -> value
*/
static async fetchAll(sourceName: string, keys: string[]): Promise<Record<string, string>> {
const results: Record<string, string> = {};
if (sourceName === 'env') {
for (const key of keys) {
results[key] = SecretSourceService.fetchFromEnv(key);
}
return results;
}
const source = SecretSourceService.resolveSource(sourceName);
if (!source) {
OrchestratorLogger.logWarning(
`Unknown secret source '${sourceName}'. Available sources: ${SecretSourceService.getAvailableSources().join(
', ',
)}`,
);
return results;
}
OrchestratorLogger.log(`Fetching ${keys.length} secret(s) from ${source.name}`);
for (const key of keys) {
results[key] = await SecretSourceService.fetchSecret(source, key);
}
return results;
}
/**
* Simple YAML parser for secret source definitions.
* Handles the specific structure we expect without requiring a YAML library.
*/
private static parseSimpleYaml(content: string): SecretSourceDefinition[] {
const definitions: SecretSourceDefinition[] = [];
const lines = content.split('\n');
let current: Partial<SecretSourceDefinition> | null = null;
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, '');
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#')) continue;
if (trimmed === '- name:' || trimmed.startsWith('- name:')) {
if (current?.name && current?.command) {
definitions.push(current as SecretSourceDefinition);
}
current = {
name: trimmed
.replace('- name:', '')
.trim()
.replace(/^['"]|['"]$/g, ''),
parseOutput: 'raw',
};
continue;
}
if (current && trimmed.startsWith('command:')) {
current.command = trimmed
.replace('command:', '')
.trim()
.replace(/^['"]|['"]$/g, '');
} else if (current && trimmed.startsWith('parseOutput:')) {
const value = trimmed
.replace('parseOutput:', '')
.trim()
.replace(/^['"]|['"]$/g, '');
current.parseOutput = value as 'raw' | 'json-field';
} else if (current && trimmed.startsWith('jsonField:')) {
current.jsonField = trimmed
.replace('jsonField:', '')
.trim()
.replace(/^['"]|['"]$/g, '');
}
}
if (current?.name && current?.command) {
definitions.push(current as SecretSourceDefinition);
}
return definitions;
}
}

View File

@@ -1,385 +0,0 @@
import fs from 'node:fs';
import { SubmoduleProfileService } from './submodule-profile-service';
import { OrchestratorSystem } from '../core/orchestrator-system';
jest.mock('node:fs');
jest.mock('../core/orchestrator-system');
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
error: jest.fn(),
},
}));
const mockedFs = fs as jest.Mocked<typeof fs>;
const mockedSystem = OrchestratorSystem as jest.Mocked<typeof OrchestratorSystem>;
describe('SubmoduleProfileService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('parseProfile', () => {
it('reads and parses a valid YAML profile', () => {
const profileYaml = `
primary_submodule: Assets/_Game/Submodules/TurnOfWarEndlessCrusade
product_name: Endless Crusade
submodules:
- name: TurnOfWar
branch: main
- name: TurnOfWarEndlessCrusade
branch: main
- name: AreaOfOperations
branch: empty
`;
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockReturnValue(profileYaml);
const profile = SubmoduleProfileService.parseProfile('/path/to/profile.yml');
expect(profile.primary_submodule).toBe('Assets/_Game/Submodules/TurnOfWarEndlessCrusade');
expect(profile.product_name).toBe('Endless Crusade');
expect(profile.submodules).toHaveLength(3);
expect(profile.submodules[0]).toEqual({ name: 'TurnOfWar', branch: 'main' });
expect(profile.submodules[1]).toEqual({ name: 'TurnOfWarEndlessCrusade', branch: 'main' });
expect(profile.submodules[2]).toEqual({ name: 'AreaOfOperations', branch: 'empty' });
});
it('throws if profile file does not exist', () => {
mockedFs.existsSync.mockReturnValue(false);
expect(() => SubmoduleProfileService.parseProfile('/missing/profile.yml')).toThrow('Submodule profile not found');
});
it('throws if YAML is missing submodules array', () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockReturnValue('product_name: Test\n');
expect(() => SubmoduleProfileService.parseProfile('/path/to/bad.yml')).toThrow("expected 'submodules' array");
});
});
describe('mergeVariant', () => {
it('correctly overlays variant entries on base profile', () => {
const baseYaml = `
submodules:
- name: ModuleA
branch: main
- name: ModuleB
branch: main
`;
const variantYaml = `
product_name: Server Build
submodules:
- name: ModuleB
branch: empty
- name: ModuleC
branch: develop
`;
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockImplementation((filePath: any) => {
if (filePath === '/base.yml') return baseYaml;
if (filePath === '/variant.yml') return variantYaml;
return '';
});
const base = SubmoduleProfileService.parseProfile('/base.yml');
const merged = SubmoduleProfileService.mergeVariant(base, '/variant.yml');
expect(merged.product_name).toBe('Server Build');
expect(merged.submodules).toHaveLength(3);
const moduleA = merged.submodules.find((s) => s.name === 'ModuleA');
const moduleB = merged.submodules.find((s) => s.name === 'ModuleB');
const moduleC = merged.submodules.find((s) => s.name === 'ModuleC');
expect(moduleA?.branch).toBe('main');
expect(moduleB?.branch).toBe('empty');
expect(moduleC?.branch).toBe('develop');
});
});
describe('matchSubmodule', () => {
it('matches exact submodule name', () => {
expect(SubmoduleProfileService.matchSubmodule('TurnOfWar', 'TurnOfWar')).toBe(true);
});
it('matches exact leaf folder name against full path', () => {
expect(SubmoduleProfileService.matchSubmodule('Assets/_Game/Submodules/TurnOfWar', 'TurnOfWar')).toBe(true);
});
it('does not match unrelated names', () => {
expect(SubmoduleProfileService.matchSubmodule('TurnOfWar', 'AreaOfOperations')).toBe(false);
});
it('matches trailing wildcard against full path', () => {
expect(SubmoduleProfileService.matchSubmodule('Assets/_Engine/Submodules/PluginsFoo', 'Plugins*')).toBe(true);
});
it('matches trailing wildcard against full path prefix', () => {
expect(
SubmoduleProfileService.matchSubmodule(
'Assets/_Engine/Submodules/PluginsFoo',
'Assets/_Engine/Submodules/Plugins*',
),
).toBe(true);
});
it('does not match wildcard that does not align', () => {
expect(SubmoduleProfileService.matchSubmodule('Assets/_Engine/Submodules/SensorToolkit', 'Plugins*')).toBe(false);
});
});
describe('parseGitmodules', () => {
it('parses a typical .gitmodules file', () => {
const gitmodulesContent = `[submodule "Assets/_Game/Submodules/TurnOfWar"]
\tpath = Assets/_Game/Submodules/TurnOfWar
\turl = https://github.com/org/TurnOfWar.git
[submodule "Assets/_Game/Submodules/EndlessCrusade"]
\tpath = Assets/_Game/Submodules/EndlessCrusade
\turl = https://github.com/org/EndlessCrusade.git
[submodule "Assets/_Engine/Submodules/SensorToolkit"]
\tpath = Assets/_Engine/Submodules/SensorToolkit
\turl = https://github.com/org/SensorToolkit.git
`;
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockReturnValue(gitmodulesContent);
const result = SubmoduleProfileService.parseGitmodules('/repo');
expect(result.size).toBe(3);
expect(result.get('Assets/_Game/Submodules/TurnOfWar')).toBe('Assets/_Game/Submodules/TurnOfWar');
expect(result.get('Assets/_Game/Submodules/EndlessCrusade')).toBe('Assets/_Game/Submodules/EndlessCrusade');
expect(result.get('Assets/_Engine/Submodules/SensorToolkit')).toBe('Assets/_Engine/Submodules/SensorToolkit');
});
it('returns empty map when .gitmodules does not exist', () => {
mockedFs.existsSync.mockReturnValue(false);
const result = SubmoduleProfileService.parseGitmodules('/repo');
expect(result.size).toBe(0);
});
});
describe('createInitPlan', () => {
it('generates correct init and skip actions', async () => {
const profileYaml = `
submodules:
- name: TurnOfWar
branch: main
- name: EndlessCrusade
branch: main
- name: SensorToolkit
branch: empty
`;
const gitmodulesContent = `[submodule "Assets/_Game/Submodules/TurnOfWar"]
\tpath = Assets/_Game/Submodules/TurnOfWar
\turl = https://github.com/org/TurnOfWar.git
[submodule "Assets/_Game/Submodules/EndlessCrusade"]
\tpath = Assets/_Game/Submodules/EndlessCrusade
\turl = https://github.com/org/EndlessCrusade.git
[submodule "Assets/_Engine/Submodules/SensorToolkit"]
\tpath = Assets/_Engine/Submodules/SensorToolkit
\turl = https://github.com/org/SensorToolkit.git
[submodule "Assets/_Game/Submodules/Unmatched"]
\tpath = Assets/_Game/Submodules/Unmatched
\turl = https://github.com/org/Unmatched.git
`;
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockImplementation((filePath: any) => {
if (String(filePath).endsWith('profile.yml')) return profileYaml;
if (String(filePath).endsWith('.gitmodules')) return gitmodulesContent;
return '';
});
const plan = await SubmoduleProfileService.createInitPlan('/path/to/profile.yml', '', '/repo');
expect(plan).toHaveLength(4);
const turnOfWar = plan.find((a) => a.name === 'Assets/_Game/Submodules/TurnOfWar');
expect(turnOfWar?.action).toBe('init');
expect(turnOfWar?.branch).toBe('main');
const endlessCrusade = plan.find((a) => a.name === 'Assets/_Game/Submodules/EndlessCrusade');
expect(endlessCrusade?.action).toBe('init');
expect(endlessCrusade?.branch).toBe('main');
const sensorToolkit = plan.find((a) => a.name === 'Assets/_Engine/Submodules/SensorToolkit');
expect(sensorToolkit?.action).toBe('skip');
expect(sensorToolkit?.branch).toBe('empty');
const unmatched = plan.find((a) => a.name === 'Assets/_Game/Submodules/Unmatched');
expect(unmatched?.action).toBe('skip');
expect(unmatched?.branch).toBe('empty');
});
it('applies variant overlay when variantPath is provided', async () => {
const profileYaml = `
submodules:
- name: TurnOfWar
branch: main
- name: EndlessCrusade
branch: main
`;
const variantYaml = `
submodules:
- name: EndlessCrusade
branch: empty
`;
const gitmodulesContent = `[submodule "Assets/_Game/Submodules/TurnOfWar"]
\tpath = Assets/_Game/Submodules/TurnOfWar
\turl = https://github.com/org/TurnOfWar.git
[submodule "Assets/_Game/Submodules/EndlessCrusade"]
\tpath = Assets/_Game/Submodules/EndlessCrusade
\turl = https://github.com/org/EndlessCrusade.git
`;
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockImplementation((filePath: any) => {
const p = String(filePath);
if (p.endsWith('profile.yml')) return profileYaml;
if (p.endsWith('variant.yml')) return variantYaml;
if (p.endsWith('.gitmodules')) return gitmodulesContent;
return '';
});
const plan = await SubmoduleProfileService.createInitPlan(
'/path/to/profile.yml',
'/path/to/variant.yml',
'/repo',
);
expect(plan).toHaveLength(2);
const turnOfWar = plan.find((a) => a.name === 'Assets/_Game/Submodules/TurnOfWar');
expect(turnOfWar?.action).toBe('init');
const endlessCrusade = plan.find((a) => a.name === 'Assets/_Game/Submodules/EndlessCrusade');
expect(endlessCrusade?.action).toBe('skip');
});
});
describe('execute', () => {
it('runs init commands for init actions and deinit for skip actions', async () => {
mockedSystem.Run.mockResolvedValue('');
const plan = [
{ name: 'ModuleA', path: 'Assets/ModuleA', branch: 'main', action: 'init' as const },
{ name: 'ModuleB', path: 'Assets/ModuleB', branch: 'develop', action: 'init' as const },
{ name: 'ModuleC', path: 'Assets/ModuleC', branch: 'empty', action: 'skip' as const },
];
await SubmoduleProfileService.execute(plan, '/repo');
// ModuleA: init only (branch is main, no checkout needed)
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/ModuleA');
// ModuleB: init + checkout develop
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/ModuleB');
expect(mockedSystem.Run).toHaveBeenCalledWith('git -C Assets/ModuleB checkout develop');
// ModuleC: deinit
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule deinit -f Assets/ModuleC 2>/dev/null || true');
});
it('configures auth when token is provided', async () => {
mockedSystem.Run.mockResolvedValue('');
await SubmoduleProfileService.execute([], '/repo', 'my-secret-token');
expect(mockedSystem.Run).toHaveBeenCalledWith(
'git config url."https://my-secret-token@github.com/".insteadOf "https://github.com/"',
);
});
it('does not configure auth when no token is provided', async () => {
mockedSystem.Run.mockResolvedValue('');
await SubmoduleProfileService.execute([], '/repo');
expect(mockedSystem.Run).not.toHaveBeenCalledWith(expect.stringContaining('git config url'));
});
});
describe('execute — command construction safety', () => {
it('constructs expected git commands for a standard path', async () => {
mockedSystem.Run.mockResolvedValue('');
const plan = [
{
name: 'Assets/_Game/Submodules/TurnOfWar',
path: 'Assets/_Game/Submodules/TurnOfWar',
branch: 'main',
action: 'init' as const,
},
];
await SubmoduleProfileService.execute(plan, '/repo');
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/_Game/Submodules/TurnOfWar');
});
it('passes path directly into git commands (paths with spaces are not quoted)', async () => {
mockedSystem.Run.mockResolvedValue('');
// This test documents current behavior: paths are passed as-is.
// If a path contained shell metacharacters, they would be passed through.
// This is acceptable because submodule paths come from .gitmodules (trusted source),
// not from user input. Still, this test documents the behavior for awareness.
const plan = [
{ name: 'Module With Spaces', path: 'Assets/Module With Spaces', branch: 'main', action: 'init' as const },
];
await SubmoduleProfileService.execute(plan, '/repo');
// The current implementation passes the path directly — no shell quoting
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/Module With Spaces');
});
it('passes branch name directly into git checkout command', async () => {
mockedSystem.Run.mockResolvedValue('');
// Document that branch names are passed as-is into shell commands.
// Branch names come from the trusted profile YAML, not user input.
const plan = [{ name: 'ModuleX', path: 'Assets/ModuleX', branch: 'feature/my-branch', action: 'init' as const }];
await SubmoduleProfileService.execute(plan, '/repo');
expect(mockedSystem.Run).toHaveBeenCalledWith('git -C Assets/ModuleX checkout feature/my-branch');
});
it('constructs deinit command with error suppression for skip actions', async () => {
mockedSystem.Run.mockResolvedValue('');
const plan = [{ name: 'Unused', path: 'Assets/Unused', branch: 'empty', action: 'skip' as const }];
await SubmoduleProfileService.execute(plan, '/repo');
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule deinit -f Assets/Unused 2>/dev/null || true');
});
it('injects token into git config URL insteadOf pattern', async () => {
mockedSystem.Run.mockResolvedValue('');
// The token is embedded directly into the URL pattern.
// This is the standard Git credential approach for CI.
await SubmoduleProfileService.execute([], '/repo', 'ghp_abc123xyz');
const configCall = mockedSystem.Run.mock.calls.find((call: any[]) => String(call[0]).includes('git config url'));
expect(configCall).toBeDefined();
expect(configCall![0]).toBe('git config url."https://ghp_abc123xyz@github.com/".insteadOf "https://github.com/"');
});
});
});

View File

@@ -1,226 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import YAML from 'yaml';
import { SubmoduleProfile, SubmoduleEntry, SubmoduleInitAction, SubmoduleInitPlan } from './submodule-profile-types';
import { OrchestratorSystem } from '../core/orchestrator-system';
import OrchestratorLogger from '../core/orchestrator-logger';
export class SubmoduleProfileService {
/**
* Parse a submodule profile YAML file and return the typed profile.
*/
static parseProfile(profilePath: string): SubmoduleProfile {
if (!fs.existsSync(profilePath)) {
throw new Error(`Submodule profile not found: ${profilePath}`);
}
const raw = fs.readFileSync(profilePath, 'utf8');
let parsed: any;
try {
parsed = YAML.parse(raw);
} catch (error: any) {
throw new Error(`Failed to parse submodule profile YAML at ${profilePath}: ${error.message}`);
}
if (!parsed || !Array.isArray(parsed.submodules)) {
throw new Error(`Invalid submodule profile: expected 'submodules' array in ${profilePath}`);
}
return {
primary_submodule: parsed.primary_submodule,
product_name: parsed.product_name,
submodules: parsed.submodules.map((entry: any) => ({
name: String(entry.name),
branch: String(entry.branch),
})),
};
}
/**
* Merge a variant profile on top of a base profile.
* Variant submodule entries override base entries matched by name.
* New variant entries are appended.
* Scalar fields (primary_submodule, product_name) are replaced by variant values.
*/
static mergeVariant(base: SubmoduleProfile, variantPath: string): SubmoduleProfile {
if (!fs.existsSync(variantPath)) {
throw new Error(`Submodule variant not found: ${variantPath}`);
}
const variant = SubmoduleProfileService.parseProfile(variantPath);
// Start with a copy of base submodules
const mergedEntries = new Map<string, SubmoduleEntry>();
for (const entry of base.submodules) {
mergedEntries.set(entry.name, { ...entry });
}
// Overlay variant entries
for (const entry of variant.submodules) {
mergedEntries.set(entry.name, { ...entry });
}
return {
primary_submodule: variant.primary_submodule ?? base.primary_submodule,
product_name: variant.product_name ?? base.product_name,
submodules: [...mergedEntries.values()],
};
}
/**
* Parse the .gitmodules file from a repository and return a map of submodule name -> path.
*/
static parseGitmodules(repoPath: string): Map<string, string> {
const gitmodulesPath = path.join(repoPath, '.gitmodules');
const result = new Map<string, string>();
if (!fs.existsSync(gitmodulesPath)) {
return result;
}
const content = fs.readFileSync(gitmodulesPath, 'utf8');
const lines = content.split('\n');
let currentName: string | undefined;
for (const line of lines) {
const trimmed = line.trim();
// Match [submodule "name"]
const submoduleMatch = trimmed.match(/^\[submodule\s+"(.+)"\]$/);
if (submoduleMatch) {
currentName = submoduleMatch[1];
continue;
}
// Match path = value
const pathMatch = trimmed.match(/^path\s*=\s*(.+)$/);
if (pathMatch && currentName) {
result.set(currentName, pathMatch[1].trim());
}
}
return result;
}
/**
* Match a submodule name/path against a profile pattern.
* Supports exact match and glob-like patterns (only `*` wildcard at end).
* Matches against both the full submodule path and the leaf folder name.
*/
static matchSubmodule(submoduleName: string, pattern: string): boolean {
// Check for trailing wildcard
if (pattern.endsWith('*')) {
const prefix = pattern.slice(0, -1);
// Match against full path
if (submoduleName.startsWith(prefix)) {
return true;
}
// Match against leaf folder name
const leaf = submoduleName.split('/').pop() || '';
if (leaf.startsWith(prefix)) {
return true;
}
return false;
}
// Exact match against full path
if (submoduleName === pattern) {
return true;
}
// Exact match against leaf folder name
const leaf = submoduleName.split('/').pop() || '';
if (leaf === pattern) {
return true;
}
return false;
}
/**
* Create an initialization plan by matching .gitmodules entries against profile rules.
* Unmatched submodules default to 'skip'.
*/
static async createInitPlan(profilePath: string, variantPath: string, repoPath: string): Promise<SubmoduleInitPlan> {
let profile = SubmoduleProfileService.parseProfile(profilePath);
if (variantPath) {
profile = SubmoduleProfileService.mergeVariant(profile, variantPath);
}
const gitmodules = SubmoduleProfileService.parseGitmodules(repoPath);
const plan: SubmoduleInitPlan = [];
for (const [name, submodulePath] of gitmodules) {
let matchedEntry: SubmoduleEntry | undefined;
for (const entry of profile.submodules) {
if (
SubmoduleProfileService.matchSubmodule(name, entry.name) ||
SubmoduleProfileService.matchSubmodule(submodulePath, entry.name)
) {
matchedEntry = entry;
break;
}
}
if (matchedEntry) {
const action: SubmoduleInitAction = {
name,
path: submodulePath,
branch: matchedEntry.branch,
action: matchedEntry.branch === 'empty' ? 'skip' : 'init',
};
plan.push(action);
} else {
// Unmatched submodules default to skip
plan.push({
name,
path: submodulePath,
branch: 'empty',
action: 'skip',
});
}
}
return plan;
}
/**
* Execute a submodule initialization plan.
* Configures auth if token is provided, then inits or deinits each submodule.
*/
static async execute(plan: SubmoduleInitPlan, repoPath: string, token?: string): Promise<void> {
if (token) {
OrchestratorLogger.log('Configuring git authentication for submodule initialization...');
await OrchestratorSystem.Run(`git config url."https://${token}@github.com/".insteadOf "https://github.com/"`);
}
for (const action of plan) {
const fullPath = path.posix.join(repoPath, action.path).replace(/\\/g, '/');
if (action.action === 'init') {
OrchestratorLogger.log(`Initializing submodule: ${action.name} (branch: ${action.branch})`);
await OrchestratorSystem.Run(`git submodule update --init ${action.path}`);
if (action.branch !== 'main') {
OrchestratorLogger.log(`Checking out branch '${action.branch}' for submodule: ${action.name}`);
await OrchestratorSystem.Run(`git -C ${action.path} checkout ${action.branch}`);
}
} else {
OrchestratorLogger.log(`Skipping submodule: ${action.name}`);
await OrchestratorSystem.Run(`git submodule deinit -f ${action.path} 2>/dev/null || true`);
}
}
OrchestratorLogger.log(
`Submodule initialization complete: ${plan.filter((a) => a.action === 'init').length} initialized, ${
plan.filter((a) => a.action === 'skip').length
} skipped`,
);
}
}

View File

@@ -1,19 +0,0 @@
export interface SubmoduleEntry {
name: string;
branch: string;
}
export interface SubmoduleProfile {
primary_submodule?: string;
product_name?: string;
submodules: SubmoduleEntry[];
}
export interface SubmoduleInitAction {
name: string;
path: string;
branch: string;
action: 'init' | 'skip';
}
export type SubmoduleInitPlan = SubmoduleInitAction[];

View File

@@ -27,7 +27,6 @@ printenv
git config --global advice.detachedHead false
git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"
git config --global filter.lfs.process "git-lfs filter-process --skip"
${OrchestratorFolders.gitAuthConfigScript}
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then

View File

@@ -92,7 +92,6 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
const commands = `mkdir -p ${OrchestratorFolders.ToLinuxFolder(
OrchestratorFolders.builderPathAbsolute,
)}
${OrchestratorFolders.gitAuthConfigScript}
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
DEST="${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.builderPathAbsolute)}"