mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-03 12:40:20 -07:00
Compare commits
17 Commits
feature/cl
...
feature/or
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf00694232 | ||
|
|
b7f6ea9194 | ||
|
|
8ccbfad587 | ||
|
|
483bab04c7 | ||
|
|
488c259481 | ||
|
|
7b8f109334 | ||
|
|
f2e7568d47 | ||
|
|
b4ffa3e070 | ||
|
|
3976b7cedd | ||
|
|
f06f99b3e5 | ||
|
|
fe63d7b32d | ||
|
|
007852a800 | ||
|
|
26903e96dd | ||
|
|
cf3478c8ec | ||
|
|
a0c79bd657 | ||
|
|
cfac5f138d | ||
|
|
5268630ef0 |
1289
.github/workflows/orchestrator-integrity.yml
vendored
1289
.github/workflows/orchestrator-integrity.yml
vendored
File diff suppressed because it is too large
Load Diff
170
.github/workflows/release-cli.yml
vendored
170
.github/workflows/release-cli.yml
vendored
@@ -1,170 +0,0 @@
|
||||
name: Release CLI
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build (e.g., v2.0.0). Uses latest release if empty.'
|
||||
required: false
|
||||
type: string
|
||||
publish-npm:
|
||||
description: 'Publish to npm'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.release.tag_name || inputs.tag || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-binaries:
|
||||
name: Build ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: linux-x64
|
||||
os: ubuntu-latest
|
||||
pkg-target: node20-linux-x64
|
||||
binary-name: game-ci-linux-x64
|
||||
- target: linux-arm64
|
||||
os: ubuntu-latest
|
||||
pkg-target: node20-linux-arm64
|
||||
binary-name: game-ci-linux-arm64
|
||||
- target: macos-x64
|
||||
os: macos-latest
|
||||
pkg-target: node20-macos-x64
|
||||
binary-name: game-ci-macos-x64
|
||||
- target: macos-arm64
|
||||
os: macos-latest
|
||||
pkg-target: node20-macos-arm64
|
||||
binary-name: game-ci-macos-arm64
|
||||
- target: windows-x64
|
||||
os: windows-latest
|
||||
pkg-target: node20-win-x64
|
||||
binary-name: game-ci-windows-x64.exe
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || inputs.tag || github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build TypeScript
|
||||
run: yarn build
|
||||
|
||||
- name: Verify CLI before packaging
|
||||
run: node lib/cli.js version
|
||||
|
||||
- name: Build standalone binary
|
||||
run: npx pkg lib/cli.js --target ${{ matrix.pkg-target }} --output ${{ matrix.binary-name }} --compress GZip
|
||||
|
||||
- name: Verify standalone binary (non-cross-compiled)
|
||||
if: |
|
||||
(matrix.target == 'linux-x64' && runner.os == 'Linux') ||
|
||||
(matrix.target == 'macos-arm64' && runner.os == 'macOS' && runner.arch == 'ARM64') ||
|
||||
(matrix.target == 'macos-x64' && runner.os == 'macOS' && runner.arch == 'X64') ||
|
||||
(matrix.target == 'windows-x64' && runner.os == 'Windows')
|
||||
run: ./${{ matrix.binary-name }} version
|
||||
shell: bash
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-${{ matrix.target }}
|
||||
path: ${{ matrix.binary-name }}
|
||||
retention-days: 5
|
||||
|
||||
create-checksums-and-upload:
|
||||
name: Checksums and release upload
|
||||
needs: build-binaries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: binaries
|
||||
pattern: binary-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List binaries
|
||||
run: ls -la binaries/
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
run: |
|
||||
cd binaries
|
||||
sha256sum game-ci-* > checksums.txt
|
||||
echo "=== checksums.txt ==="
|
||||
cat checksums.txt
|
||||
|
||||
- name: Determine release tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
|
||||
elif [ -n "${{ inputs.tag }}" ]; then
|
||||
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No release tag available. Skipping upload."
|
||||
echo "tag=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload binaries to release
|
||||
if: steps.tag.outputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
cd binaries
|
||||
for f in game-ci-* checksums.txt; do
|
||||
echo "Uploading $f..."
|
||||
gh release upload "${{ steps.tag.outputs.tag }}" "$f" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--clobber
|
||||
done
|
||||
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
needs: build-binaries
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event_name == 'release') || (github.event_name == 'workflow_dispatch' && inputs.publish-npm)
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || inputs.tag || github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
||||
- name: Run tests
|
||||
run: yarn test
|
||||
|
||||
- name: Verify CLI
|
||||
run: |
|
||||
node lib/cli.js version
|
||||
node lib/cli.js --help
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
127
action.yml
127
action.yml
@@ -182,8 +182,8 @@ inputs:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with the
|
||||
keys image, secrets (name, value object array), command line string)'
|
||||
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with
|
||||
the keys image, secrets (name, value object array), command line string)'
|
||||
awsStackName:
|
||||
default: 'game-ci'
|
||||
required: false
|
||||
@@ -279,6 +279,129 @@ 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.'
|
||||
middleware:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'[Orchestrator] Inline YAML middleware pipeline definitions. Middleware wraps around build phases with
|
||||
before/after semantics, supports trigger conditions (phase, provider, platform, environment expressions), and
|
||||
resolves to either command hooks (inline in build container) or container hooks (separate Docker containers).
|
||||
Middleware executes in priority order — lower priority values run first for before phases and last for after
|
||||
phases (wrapping pattern). See documentation for full YAML format.'
|
||||
middlewareFiles:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'[Orchestrator] Comma-separated list of middleware file names (without extension) to load from the
|
||||
game-ci/middleware/ directory in your repository. Each file should contain YAML middleware definitions.'
|
||||
|
||||
outputs:
|
||||
volume:
|
||||
|
||||
2631
dist/index.js
generated
vendored
2631
dist/index.js
generated
vendored
File diff suppressed because it is too large
Load Diff
2
dist/index.js.map
generated
vendored
2
dist/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
122
install.ps1
122
install.ps1
@@ -1,122 +0,0 @@
|
||||
# game-ci CLI installer for Windows
|
||||
# Usage: irm https://raw.githubusercontent.com/game-ci/unity-builder/main/install.ps1 | iex
|
||||
#
|
||||
# Environment variables:
|
||||
# GAME_CI_VERSION - Install a specific version (e.g., v2.0.0). Defaults to latest.
|
||||
# GAME_CI_INSTALL - Installation directory. Defaults to $HOME\.game-ci\bin.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Repo = "game-ci/unity-builder"
|
||||
$InstallDir = if ($env:GAME_CI_INSTALL) { $env:GAME_CI_INSTALL } else { Join-Path $env:USERPROFILE ".game-ci\bin" }
|
||||
$AssetName = "game-ci-windows-x64.exe"
|
||||
$BinaryName = "game-ci.exe"
|
||||
|
||||
function Write-Info($Message) {
|
||||
Write-Host "info: " -ForegroundColor Green -NoNewline
|
||||
Write-Host $Message
|
||||
}
|
||||
|
||||
function Write-Warn($Message) {
|
||||
Write-Host "warn: " -ForegroundColor Yellow -NoNewline
|
||||
Write-Host $Message
|
||||
}
|
||||
|
||||
# Determine version
|
||||
if ($env:GAME_CI_VERSION) {
|
||||
$Version = $env:GAME_CI_VERSION
|
||||
Write-Info "Using specified version: $Version"
|
||||
} else {
|
||||
Write-Info "Fetching latest release..."
|
||||
try {
|
||||
$Release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest"
|
||||
$Version = $Release.tag_name
|
||||
} catch {
|
||||
Write-Host "error: Could not determine latest version. Check https://github.com/$Repo/releases" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
$DownloadUrl = "https://github.com/$Repo/releases/download/$Version/$AssetName"
|
||||
$ChecksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt"
|
||||
$BinaryPath = Join-Path $InstallDir $BinaryName
|
||||
|
||||
Write-Host ""
|
||||
Write-Info "Installing game-ci $Version (windows-x64)"
|
||||
Write-Info " from: $DownloadUrl"
|
||||
Write-Info " to: $BinaryPath"
|
||||
Write-Host ""
|
||||
|
||||
# Create install directory
|
||||
if (-not (Test-Path $InstallDir)) {
|
||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||
}
|
||||
|
||||
# Download binary
|
||||
try {
|
||||
Invoke-WebRequest -Uri $DownloadUrl -OutFile $BinaryPath -UseBasicParsing
|
||||
} catch {
|
||||
if ($_.Exception.Response.StatusCode -eq 404) {
|
||||
Write-Host "error: Release asset not found: $AssetName ($Version)" -ForegroundColor Red
|
||||
Write-Host " Check available assets at https://github.com/$Repo/releases/tag/$Version" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "error: Download failed: $_" -ForegroundColor Red
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Verify checksum
|
||||
try {
|
||||
$Checksums = Invoke-WebRequest -Uri $ChecksumUrl -UseBasicParsing | Select-Object -ExpandProperty Content
|
||||
$ExpectedLine = $Checksums -split "`n" | Where-Object { $_ -match $AssetName } | Select-Object -First 1
|
||||
if ($ExpectedLine) {
|
||||
$ExpectedHash = ($ExpectedLine -split '\s+')[0]
|
||||
$ActualHash = (Get-FileHash -Path $BinaryPath -Algorithm SHA256).Hash.ToLower()
|
||||
if ($ExpectedHash -eq $ActualHash) {
|
||||
Write-Info "Checksum verified (SHA256)"
|
||||
} else {
|
||||
Write-Host "error: Checksum verification failed!" -ForegroundColor Red
|
||||
Write-Host " Expected: $ExpectedHash" -ForegroundColor Red
|
||||
Write-Host " Got: $ActualHash" -ForegroundColor Red
|
||||
Remove-Item $BinaryPath -Force
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# Checksums not available for this release; continue without verification
|
||||
}
|
||||
|
||||
# Verify the binary works
|
||||
try {
|
||||
$VersionOutput = & $BinaryPath version 2>&1
|
||||
Write-Info "Verified: $($VersionOutput | Select-Object -First 1)"
|
||||
} catch {
|
||||
Write-Warn "Binary downloaded but could not verify. It may still work."
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "game-ci installed successfully!" -ForegroundColor Green -BackgroundColor Black
|
||||
Write-Host ""
|
||||
|
||||
# Check PATH and offer to add
|
||||
$UserPath = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||
if ($UserPath -notlike "*$InstallDir*") {
|
||||
Write-Warn "game-ci is not in your PATH."
|
||||
Write-Host ""
|
||||
Write-Host "To add it permanently, run:" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host " [Environment]::SetEnvironmentVariable('PATH', ""$InstallDir;"" + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')"
|
||||
Write-Host ""
|
||||
Write-Info "Then restart your terminal."
|
||||
|
||||
# Offer to add automatically
|
||||
Write-Host ""
|
||||
$AddToPath = Read-Host "Add to PATH now? (Y/n)"
|
||||
if ($AddToPath -ne 'n' -and $AddToPath -ne 'N') {
|
||||
[Environment]::SetEnvironmentVariable('PATH', "$InstallDir;$UserPath", 'User')
|
||||
$env:PATH = "$InstallDir;$env:PATH"
|
||||
Write-Info "Added to PATH. You can now run: game-ci --help"
|
||||
}
|
||||
} else {
|
||||
Write-Info "game-ci is already in your PATH. Run: game-ci --help"
|
||||
}
|
||||
196
install.sh
196
install.sh
@@ -1,196 +0,0 @@
|
||||
#!/bin/sh
|
||||
# game-ci CLI installer
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/game-ci/unity-builder/main/install.sh | sh
|
||||
#
|
||||
# Environment variables:
|
||||
# GAME_CI_VERSION - Install a specific version (e.g., v2.0.0). Defaults to latest.
|
||||
# GAME_CI_INSTALL - Installation directory. Defaults to ~/.game-ci/bin.
|
||||
|
||||
set -e
|
||||
|
||||
REPO="game-ci/unity-builder"
|
||||
INSTALL_DIR="${GAME_CI_INSTALL:-$HOME/.game-ci/bin}"
|
||||
BINARY_NAME="game-ci"
|
||||
|
||||
# Colors (disabled if not a terminal)
|
||||
if [ -t 1 ]; then
|
||||
BOLD='\033[1m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
RED='\033[0;31m'
|
||||
RESET='\033[0m'
|
||||
else
|
||||
BOLD=''
|
||||
GREEN=''
|
||||
YELLOW=''
|
||||
RED=''
|
||||
RESET=''
|
||||
fi
|
||||
|
||||
info() {
|
||||
printf "${GREEN}info${RESET}: %s\n" "$1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf "${YELLOW}warn${RESET}: %s\n" "$1"
|
||||
}
|
||||
|
||||
error() {
|
||||
printf "${RED}error${RESET}: %s\n" "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Detect OS and architecture
|
||||
detect_platform() {
|
||||
OS="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
case "$OS" in
|
||||
Linux*) PLATFORM="linux" ;;
|
||||
Darwin*) PLATFORM="macos" ;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
PLATFORM="windows"
|
||||
warn "For Windows, consider using install.ps1 instead:"
|
||||
warn " irm https://raw.githubusercontent.com/game-ci/unity-builder/main/install.ps1 | iex"
|
||||
;;
|
||||
*) error "Unsupported operating system: $OS" ;;
|
||||
esac
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) ARCH="x64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
*) error "Unsupported architecture: $ARCH" ;;
|
||||
esac
|
||||
|
||||
ASSET_NAME="game-ci-${PLATFORM}-${ARCH}"
|
||||
if [ "$PLATFORM" = "windows" ]; then
|
||||
ASSET_NAME="${ASSET_NAME}.exe"
|
||||
BINARY_NAME="game-ci.exe"
|
||||
fi
|
||||
}
|
||||
|
||||
# Get latest release tag from GitHub API
|
||||
get_latest_version() {
|
||||
if [ -n "$GAME_CI_VERSION" ]; then
|
||||
VERSION="$GAME_CI_VERSION"
|
||||
info "Using specified version: $VERSION"
|
||||
return
|
||||
fi
|
||||
|
||||
info "Fetching latest release..."
|
||||
|
||||
if command -v curl > /dev/null 2>&1; then
|
||||
VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
elif command -v wget > /dev/null 2>&1; then
|
||||
VERSION=$(wget -qO- "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
else
|
||||
error "Neither curl nor wget found. Please install one of them."
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
error "Could not determine latest version. Check https://github.com/${REPO}/releases"
|
||||
fi
|
||||
}
|
||||
|
||||
# Download and install the binary
|
||||
install() {
|
||||
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET_NAME}"
|
||||
|
||||
printf "\n"
|
||||
info "Installing game-ci ${VERSION} (${PLATFORM}-${ARCH})"
|
||||
info " from: ${DOWNLOAD_URL}"
|
||||
info " to: ${INSTALL_DIR}/${BINARY_NAME}"
|
||||
printf "\n"
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Download with progress
|
||||
if command -v curl > /dev/null 2>&1; then
|
||||
HTTP_CODE=$(curl -fSL "$DOWNLOAD_URL" -o "${INSTALL_DIR}/${BINARY_NAME}" -w "%{http_code}" 2>/dev/null) || true
|
||||
if [ "$HTTP_CODE" = "404" ]; then
|
||||
error "Release asset not found: ${ASSET_NAME} (${VERSION}). Check available assets at https://github.com/${REPO}/releases/tag/${VERSION}"
|
||||
elif [ ! -f "${INSTALL_DIR}/${BINARY_NAME}" ]; then
|
||||
error "Download failed. URL: ${DOWNLOAD_URL}"
|
||||
fi
|
||||
elif command -v wget > /dev/null 2>&1; then
|
||||
wget -q "$DOWNLOAD_URL" -O "${INSTALL_DIR}/${BINARY_NAME}" || error "Download failed. URL: ${DOWNLOAD_URL}"
|
||||
fi
|
||||
|
||||
chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
|
||||
# Verify the binary works
|
||||
if "${INSTALL_DIR}/${BINARY_NAME}" version > /dev/null 2>&1; then
|
||||
INSTALLED_VERSION=$("${INSTALL_DIR}/${BINARY_NAME}" version 2>&1 | head -1)
|
||||
info "Verified: ${INSTALLED_VERSION}"
|
||||
else
|
||||
warn "Binary downloaded but could not verify. It may still work."
|
||||
fi
|
||||
|
||||
printf "\n"
|
||||
printf "${BOLD}game-ci installed successfully!${RESET}\n"
|
||||
printf "\n"
|
||||
|
||||
# Check if install dir is in PATH
|
||||
case ":$PATH:" in
|
||||
*":${INSTALL_DIR}:"*)
|
||||
info "game-ci is already in your PATH. Run: game-ci --help"
|
||||
;;
|
||||
*)
|
||||
SHELL_NAME=$(basename "$SHELL" 2>/dev/null || echo "sh")
|
||||
case "$SHELL_NAME" in
|
||||
zsh) PROFILE="~/.zshrc" ;;
|
||||
bash) PROFILE="~/.bashrc" ;;
|
||||
fish) PROFILE="~/.config/fish/config.fish" ;;
|
||||
*) PROFILE="~/.profile" ;;
|
||||
esac
|
||||
|
||||
printf "${YELLOW}Add game-ci to your PATH by adding this to ${PROFILE}:${RESET}\n"
|
||||
printf "\n"
|
||||
if [ "$SHELL_NAME" = "fish" ]; then
|
||||
printf " set -gx PATH \"%s\" \$PATH\n" "$INSTALL_DIR"
|
||||
else
|
||||
printf " export PATH=\"%s:\$PATH\"\n" "$INSTALL_DIR"
|
||||
fi
|
||||
printf "\n"
|
||||
info "Then restart your shell or run: source ${PROFILE}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Verify checksum if checksums.txt is available
|
||||
verify_checksum() {
|
||||
if ! command -v sha256sum > /dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
CHECKSUM_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt"
|
||||
|
||||
CHECKSUMS=""
|
||||
if command -v curl > /dev/null 2>&1; then
|
||||
CHECKSUMS=$(curl -fsSL "$CHECKSUM_URL" 2>/dev/null) || return 0
|
||||
elif command -v wget > /dev/null 2>&1; then
|
||||
CHECKSUMS=$(wget -qO- "$CHECKSUM_URL" 2>/dev/null) || return 0
|
||||
fi
|
||||
|
||||
if [ -z "$CHECKSUMS" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
EXPECTED=$(echo "$CHECKSUMS" | grep "$ASSET_NAME" | awk '{print $1}')
|
||||
if [ -z "$EXPECTED" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
ACTUAL=$(sha256sum "${INSTALL_DIR}/${BINARY_NAME}" | awk '{print $1}')
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
error "Checksum verification failed!\n Expected: ${EXPECTED}\n Got: ${ACTUAL}"
|
||||
fi
|
||||
|
||||
info "Checksum verified (SHA256)"
|
||||
}
|
||||
|
||||
# Main
|
||||
detect_platform
|
||||
get_latest_version
|
||||
install
|
||||
verify_checksum
|
||||
24
package.json
24
package.json
@@ -3,24 +3,6 @@
|
||||
"version": "3.0.0",
|
||||
"description": "Build Unity projects for different platforms.",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"game-ci": "./lib/cli.js"
|
||||
},
|
||||
"pkg": {
|
||||
"scripts": "lib/**/*.js",
|
||||
"assets": [
|
||||
"lib/**/*.json",
|
||||
"package.json"
|
||||
],
|
||||
"targets": [
|
||||
"node20-linux-x64",
|
||||
"node20-linux-arm64",
|
||||
"node20-macos-x64",
|
||||
"node20-macos-arm64",
|
||||
"node20-win-x64"
|
||||
],
|
||||
"outputPath": "dist-binaries"
|
||||
},
|
||||
"repository": "git@github.com:game-ci/unity-builder.git",
|
||||
"author": "Webber <webber@takken.io>",
|
||||
"license": "MIT",
|
||||
@@ -30,7 +12,6 @@
|
||||
"lint": "prettier --check \"src/**/*.{js,ts}\" && eslint src/**/*.ts",
|
||||
"format": "prettier --write \"src/**/*.{js,ts}\"",
|
||||
"cli": "yarn ts-node src/index.ts -m cli",
|
||||
"game-ci": "ts-node src/cli.ts",
|
||||
"gcp-secrets-tests": "cross-env providerStrategy=aws orchestratorTests=true inputPullCommand=\"gcp-secret-manager\" populateOverride=true pullInputList=UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD yarn test -i -t \"orchestrator\"",
|
||||
"gcp-secrets-cli": "cross-env orchestratorTests=true USE_IL2CPP=false inputPullCommand=\"gcp-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
|
||||
"aws-secrets-cli": "cross-env orchestratorTests=true inputPullCommand=\"aws-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
|
||||
@@ -73,8 +54,7 @@
|
||||
"ts-md5": "^1.3.1",
|
||||
"unity-changeset": "^3.1.0",
|
||||
"uuid": "^9.0.0",
|
||||
"yaml": "^2.2.2",
|
||||
"yargs": "^17.7.2"
|
||||
"yaml": "^2.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/base-64": "^1.0.0",
|
||||
@@ -82,7 +62,6 @@
|
||||
"@types/node": "^17.0.23",
|
||||
"@types/semver": "^7.3.9",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"@types/yargs": "^17.0.35",
|
||||
"@typescript-eslint/parser": "4.8.1",
|
||||
"@vercel/ncc": "^0.36.1",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -98,7 +77,6 @@
|
||||
"js-yaml": "^4.1.0",
|
||||
"lefthook": "^1.6.1",
|
||||
"node-fetch": "2",
|
||||
"pkg": "^5.8.1",
|
||||
"prettier": "^2.5.1",
|
||||
"ts-jest": "^27.1.3",
|
||||
"ts-node": "10.8.1",
|
||||
|
||||
42
src/cli.ts
42
src/cli.ts
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import yargs from 'yargs';
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import buildCommand from './cli/commands/build';
|
||||
import testCommand from './cli/commands/test';
|
||||
import orchestrateCommand from './cli/commands/orchestrate';
|
||||
import activateCommand from './cli/commands/activate';
|
||||
import statusCommand from './cli/commands/status';
|
||||
import versionCommand from './cli/commands/version';
|
||||
import updateCommand from './cli/commands/update';
|
||||
import * as core from '@actions/core';
|
||||
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
.scriptName('game-ci')
|
||||
.usage('$0 <command> [options]')
|
||||
.command(buildCommand)
|
||||
.command(testCommand)
|
||||
.command(orchestrateCommand)
|
||||
.command(activateCommand)
|
||||
.command(statusCommand)
|
||||
.command(versionCommand)
|
||||
.command(updateCommand)
|
||||
.demandCommand(1, 'You must specify a command. Run game-ci --help for available commands.')
|
||||
.strict()
|
||||
.alias('h', 'help')
|
||||
.epilogue('For more information, visit https://game.ci')
|
||||
.wrap(Math.min(120, process.stdout.columns || 80));
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await cli.parse();
|
||||
} catch (error: any) {
|
||||
if (error.name !== 'YError') {
|
||||
core.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,135 +0,0 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Integration tests that spawn the CLI as a child process and verify
|
||||
* exit codes and output. Uses node with --require ts-node/register to
|
||||
* run the TypeScript entry point directly so no build step is required.
|
||||
*/
|
||||
|
||||
const CLI_ENTRY = path.resolve(__dirname, '..', '..', 'cli.ts');
|
||||
|
||||
function runCli(cliArguments: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
['--require', 'ts-node/register/transpile-only', CLI_ENTRY, ...cliArguments],
|
||||
{ timeout: 30_000, cwd: path.resolve(__dirname, '..', '..', '..') },
|
||||
(error, stdout, stderr) => {
|
||||
resolve({
|
||||
code: error ? error.code ?? 1 : 0,
|
||||
stdout: stdout.toString(),
|
||||
stderr: stderr.toString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Integration tests spawn child processes which need more time than the default 5s
|
||||
jest.setTimeout(30_000);
|
||||
|
||||
describe('CLI integration', () => {
|
||||
it('exits 0 and shows all commands for --help', async () => {
|
||||
const result = await runCli(['--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('game-ci');
|
||||
expect(result.stdout).toContain('build');
|
||||
expect(result.stdout).toContain('test');
|
||||
expect(result.stdout).toContain('orchestrate');
|
||||
expect(result.stdout).toContain('activate');
|
||||
expect(result.stdout).toContain('status');
|
||||
expect(result.stdout).toContain('version');
|
||||
expect(result.stdout).toContain('update');
|
||||
});
|
||||
|
||||
it('exits 0 and shows version info for version command', async () => {
|
||||
const result = await runCli(['version']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('unity-builder');
|
||||
});
|
||||
|
||||
it('exits 0 and shows build flags for build --help', async () => {
|
||||
const result = await runCli(['build', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('--target-platform');
|
||||
expect(result.stdout).toContain('--unity-version');
|
||||
expect(result.stdout).toContain('--project-path');
|
||||
expect(result.stdout).toContain('--build-name');
|
||||
expect(result.stdout).toContain('--builds-path');
|
||||
expect(result.stdout).toContain('--build-method');
|
||||
expect(result.stdout).toContain('--custom-parameters');
|
||||
expect(result.stdout).toContain('--versioning');
|
||||
});
|
||||
|
||||
it('exits 0 and shows test flags for test --help', async () => {
|
||||
const result = await runCli(['test', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('--target-platform');
|
||||
expect(result.stdout).toContain('--test-mode');
|
||||
expect(result.stdout).toContain('--test-results-path');
|
||||
expect(result.stdout).toContain('--enable-code-coverage');
|
||||
});
|
||||
|
||||
it('exits 0 for test alias t --help', async () => {
|
||||
const result = await runCli(['t', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('--test-mode');
|
||||
});
|
||||
|
||||
it('exits non-zero for an unknown command', async () => {
|
||||
const result = await runCli(['nonexistent']);
|
||||
|
||||
expect(result.code).not.toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('exits non-zero when no command is provided', async () => {
|
||||
const result = await runCli([]);
|
||||
|
||||
expect(result.code).not.toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('exits 0 for orchestrate --help', async () => {
|
||||
const result = await runCli(['orchestrate', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('--target-platform');
|
||||
expect(result.stdout).toContain('--provider-strategy');
|
||||
});
|
||||
|
||||
it('exits 0 for orchestrate alias o --help', async () => {
|
||||
const result = await runCli(['o', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('--provider-strategy');
|
||||
});
|
||||
|
||||
it('exits 0 for activate --help', async () => {
|
||||
const result = await runCli(['activate', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('activate');
|
||||
});
|
||||
|
||||
it('exits 0 for status --help', async () => {
|
||||
const result = await runCli(['status', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('status');
|
||||
expect(result.stdout).toContain('--cache-dir');
|
||||
});
|
||||
|
||||
it('exits 0 for update --help', async () => {
|
||||
const result = await runCli(['update', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('update');
|
||||
expect(result.stdout).toContain('--force');
|
||||
expect(result.stdout).toContain('--version');
|
||||
});
|
||||
});
|
||||
@@ -1,312 +0,0 @@
|
||||
import buildCommand from '../commands/build';
|
||||
import testCommand from '../commands/test';
|
||||
import activateCommand from '../commands/activate';
|
||||
import orchestrateCommand from '../commands/orchestrate';
|
||||
import statusCommand from '../commands/status';
|
||||
import versionCommand from '../commands/version';
|
||||
import updateCommand from '../commands/update';
|
||||
|
||||
function createFakeYargs(): { yargs: any; options: Record<string, any> } {
|
||||
const options: Record<string, any> = {};
|
||||
const yargs: any = {
|
||||
option: jest.fn(),
|
||||
positional: jest.fn(),
|
||||
example: jest.fn(),
|
||||
env: jest.fn(),
|
||||
};
|
||||
|
||||
yargs.option.mockImplementation((name: string, config: any) => {
|
||||
options[name] = config;
|
||||
|
||||
return yargs;
|
||||
});
|
||||
yargs.positional.mockImplementation((name: string, config: any) => {
|
||||
options[name] = config;
|
||||
|
||||
return yargs;
|
||||
});
|
||||
yargs.example.mockReturnValue(yargs);
|
||||
yargs.env.mockReturnValue(yargs);
|
||||
|
||||
return { yargs, options };
|
||||
}
|
||||
|
||||
describe('CLI commands', () => {
|
||||
describe('build command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(buildCommand.command).toStrictEqual('build');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(buildCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof buildCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof buildCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('defines all expected build flags via builder', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(buildCommand.builder as Function)(yargs);
|
||||
|
||||
// Core build flags (from shared + build-specific)
|
||||
expect(options['target-platform']).toBeDefined();
|
||||
expect(options['target-platform'].demandOption).toStrictEqual(true);
|
||||
expect(options['unity-version']).toBeDefined();
|
||||
expect(options['project-path']).toBeDefined();
|
||||
expect(options['build-profile']).toBeDefined();
|
||||
expect(options['build-name']).toBeDefined();
|
||||
expect(options['builds-path']).toBeDefined();
|
||||
expect(options['build-method']).toBeDefined();
|
||||
expect(options['custom-parameters']).toBeDefined();
|
||||
expect(options['versioning']).toBeDefined();
|
||||
expect(options['version']).toBeDefined();
|
||||
expect(options['custom-image']).toBeDefined();
|
||||
expect(options['manual-exit']).toBeDefined();
|
||||
expect(options['enable-gpu']).toBeDefined();
|
||||
|
||||
// Android flags
|
||||
expect(options['android-version-code']).toBeDefined();
|
||||
expect(options['android-export-type']).toBeDefined();
|
||||
expect(options['android-keystore-name']).toBeDefined();
|
||||
expect(options['android-keystore-base64']).toBeDefined();
|
||||
expect(options['android-keystore-pass']).toBeDefined();
|
||||
expect(options['android-keyalias-name']).toBeDefined();
|
||||
expect(options['android-keyalias-pass']).toBeDefined();
|
||||
expect(options['android-target-sdk-version']).toBeDefined();
|
||||
expect(options['android-symbol-type']).toBeDefined();
|
||||
|
||||
// Docker flags
|
||||
expect(options['docker-cpu-limit']).toBeDefined();
|
||||
expect(options['docker-memory-limit']).toBeDefined();
|
||||
expect(options['docker-workspace-path']).toBeDefined();
|
||||
expect(options['run-as-host-user']).toBeDefined();
|
||||
expect(options['chown-files-to']).toBeDefined();
|
||||
|
||||
// Build should NOT have orchestrator-specific flags
|
||||
expect(options['provider-strategy']).toBeUndefined();
|
||||
expect(options['aws-stack-name']).toBeUndefined();
|
||||
expect(options['kube-config']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets correct default values', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(buildCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['unity-version'].default).toStrictEqual('auto');
|
||||
expect(options['project-path'].default).toStrictEqual('.');
|
||||
expect(options['builds-path'].default).toStrictEqual('build');
|
||||
expect(options['versioning'].default).toStrictEqual('Semantic');
|
||||
expect(options['manual-exit'].default).toStrictEqual(false);
|
||||
expect(options['enable-gpu'].default).toStrictEqual(false);
|
||||
expect(options['android-export-type'].default).toStrictEqual('androidPackage');
|
||||
expect(options['android-symbol-type'].default).toStrictEqual('none');
|
||||
});
|
||||
|
||||
it('provides camelCase aliases for kebab-case options', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(buildCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['target-platform'].alias).toStrictEqual('targetPlatform');
|
||||
expect(options['unity-version'].alias).toStrictEqual('unityVersion');
|
||||
expect(options['project-path'].alias).toStrictEqual('projectPath');
|
||||
expect(options['build-name'].alias).toStrictEqual('buildName');
|
||||
expect(options['builds-path'].alias).toStrictEqual('buildsPath');
|
||||
expect(options['build-method'].alias).toStrictEqual('buildMethod');
|
||||
});
|
||||
});
|
||||
|
||||
describe('test command', () => {
|
||||
it('exports command with alias', () => {
|
||||
expect(testCommand.command).toStrictEqual(['test', 't']);
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(testCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof testCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof testCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('defines test-specific flags', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(testCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['test-mode']).toBeDefined();
|
||||
expect(options['test-mode'].default).toStrictEqual('All');
|
||||
expect(options['test-mode'].choices).toStrictEqual(['EditMode', 'PlayMode', 'All']);
|
||||
expect(options['test-results-path']).toBeDefined();
|
||||
expect(options['test-category']).toBeDefined();
|
||||
expect(options['test-filter']).toBeDefined();
|
||||
expect(options['enable-code-coverage']).toBeDefined();
|
||||
expect(options['coverage-options']).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes shared project options', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(testCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['target-platform']).toBeDefined();
|
||||
expect(options['target-platform'].demandOption).toStrictEqual(true);
|
||||
expect(options['unity-version']).toBeDefined();
|
||||
expect(options['project-path']).toBeDefined();
|
||||
expect(options['custom-image']).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes docker options but not orchestrator options', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(testCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['docker-cpu-limit']).toBeDefined();
|
||||
expect(options['docker-memory-limit']).toBeDefined();
|
||||
expect(options['provider-strategy']).toBeUndefined();
|
||||
expect(options['aws-stack-name']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('activate command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(activateCommand.command).toStrictEqual('activate');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(activateCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof activateCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof activateCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('orchestrate command', () => {
|
||||
it('exports command with alias', () => {
|
||||
expect(orchestrateCommand.command).toStrictEqual(['orchestrate', 'o']);
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(orchestrateCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof orchestrateCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof orchestrateCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('defines key orchestrator flags', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(orchestrateCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['target-platform']).toBeDefined();
|
||||
expect(options['target-platform'].demandOption).toStrictEqual(true);
|
||||
expect(options['provider-strategy']).toBeDefined();
|
||||
expect(options['provider-strategy'].default).toStrictEqual('aws');
|
||||
expect(options['aws-stack-name']).toBeDefined();
|
||||
expect(options['kube-config']).toBeDefined();
|
||||
expect(options['kube-volume']).toBeDefined();
|
||||
expect(options['cache-key']).toBeDefined();
|
||||
expect(options['watch-to-end']).toBeDefined();
|
||||
expect(options['clone-depth']).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not include build-only options', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(orchestrateCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['build-profile']).toBeUndefined();
|
||||
expect(options['manual-exit']).toBeUndefined();
|
||||
expect(options['enable-gpu']).toBeUndefined();
|
||||
expect(options['android-version-code']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(statusCommand.command).toStrictEqual('status');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(statusCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof statusCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('includes cache-dir option', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(statusCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['cache-dir']).toBeDefined();
|
||||
expect(options['project-path']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('version command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(versionCommand.command).toStrictEqual('version');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(versionCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof versionCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(updateCommand.command).toStrictEqual('update');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(updateCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof updateCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof updateCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('defines force and version flags', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(updateCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['force']).toBeDefined();
|
||||
expect(options['force'].type).toStrictEqual('boolean');
|
||||
expect(options['force'].default).toStrictEqual(false);
|
||||
expect(options['version']).toBeDefined();
|
||||
expect(options['version'].type).toStrictEqual('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
import { Cli } from '../../model/cli/cli';
|
||||
import GitHub from '../../model/github';
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
Cli.options = undefined;
|
||||
});
|
||||
|
||||
describe('mapCliArgumentsToInput', () => {
|
||||
describe('basic mapping', () => {
|
||||
it('populates Cli.options from CLI arguments', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
unityVersion: '2022.3.56f1',
|
||||
projectPath: './my-project',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options).toBeDefined();
|
||||
expect(Cli.options!['targetPlatform']).toStrictEqual('StandaloneLinux64');
|
||||
expect(Cli.options!['unityVersion']).toStrictEqual('2022.3.56f1');
|
||||
expect(Cli.options!['projectPath']).toStrictEqual('./my-project');
|
||||
});
|
||||
|
||||
it('disables GitHub Actions input reading', () => {
|
||||
const cliArguments: CliArguments = { targetPlatform: 'WebGL' };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(GitHub.githubInputEnabled).toStrictEqual(false);
|
||||
});
|
||||
|
||||
it('sets mode to cli by default when not provided', () => {
|
||||
const cliArguments: CliArguments = { targetPlatform: 'Android' };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['mode']).toStrictEqual('cli');
|
||||
});
|
||||
|
||||
it('preserves an explicitly provided mode', () => {
|
||||
const cliArguments: CliArguments = { targetPlatform: 'Android', mode: 'custom-mode' };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['mode']).toStrictEqual('custom-mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default values', () => {
|
||||
it('omits undefined values from Cli.options', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
unityVersion: undefined,
|
||||
buildName: undefined,
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['targetPlatform']).toStrictEqual('StandaloneLinux64');
|
||||
expect(Cli.options!).not.toHaveProperty('unityVersion');
|
||||
expect(Cli.options!).not.toHaveProperty('buildName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean conversion', () => {
|
||||
it('converts boolean true to string "true"', () => {
|
||||
const cliArguments: CliArguments = { manualExit: true };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['manualExit']).toStrictEqual('true');
|
||||
});
|
||||
|
||||
it('converts boolean false to string "false"', () => {
|
||||
const cliArguments: CliArguments = { enableGpu: false };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['enableGpu']).toStrictEqual('false');
|
||||
});
|
||||
|
||||
it('converts allowDirtyBuild boolean to string', () => {
|
||||
const cliArguments: CliArguments = { allowDirtyBuild: true };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['allowDirtyBuild']).toStrictEqual('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('yargs internal properties', () => {
|
||||
it('filters out yargs _ property', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
targetPlatform: 'iOS',
|
||||
_: ['build'] as any,
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!).not.toHaveProperty('_');
|
||||
});
|
||||
|
||||
it('filters out yargs $0 property', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
targetPlatform: 'iOS',
|
||||
$0: 'game-ci' as any,
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!).not.toHaveProperty('$0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('flag name conversion', () => {
|
||||
it('passes camelCase keys through directly', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
androidKeystoreName: 'my.keystore',
|
||||
androidKeystorePass: 'secret',
|
||||
dockerCpuLimit: '4',
|
||||
dockerMemoryLimit: '8g',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['androidKeystoreName']).toStrictEqual('my.keystore');
|
||||
expect(Cli.options!['androidKeystorePass']).toStrictEqual('secret');
|
||||
expect(Cli.options!['dockerCpuLimit']).toStrictEqual('4');
|
||||
expect(Cli.options!['dockerMemoryLimit']).toStrictEqual('8g');
|
||||
});
|
||||
|
||||
it('maps all android-related arguments', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
androidVersionCode: '42',
|
||||
androidExportType: 'androidAppBundle',
|
||||
androidKeystoreBase64: 'base64data',
|
||||
androidKeyaliasName: 'myalias',
|
||||
androidKeyaliasPass: 'aliaspass',
|
||||
androidTargetSdkVersion: '33',
|
||||
androidSymbolType: 'public',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['androidVersionCode']).toStrictEqual('42');
|
||||
expect(Cli.options!['androidExportType']).toStrictEqual('androidAppBundle');
|
||||
expect(Cli.options!['androidKeystoreBase64']).toStrictEqual('base64data');
|
||||
expect(Cli.options!['androidKeyaliasName']).toStrictEqual('myalias');
|
||||
expect(Cli.options!['androidKeyaliasPass']).toStrictEqual('aliaspass');
|
||||
expect(Cli.options!['androidTargetSdkVersion']).toStrictEqual('33');
|
||||
expect(Cli.options!['androidSymbolType']).toStrictEqual('public');
|
||||
});
|
||||
|
||||
it('maps docker and container arguments', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
dockerIsolationMode: 'hyperv',
|
||||
dockerWorkspacePath: '/custom/workspace',
|
||||
containerRegistryRepository: 'custom/editor',
|
||||
containerRegistryImageVersion: '5',
|
||||
runAsHostUser: 'true',
|
||||
chownFilesTo: 'root:root',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['dockerIsolationMode']).toStrictEqual('hyperv');
|
||||
expect(Cli.options!['dockerWorkspacePath']).toStrictEqual('/custom/workspace');
|
||||
expect(Cli.options!['containerRegistryRepository']).toStrictEqual('custom/editor');
|
||||
expect(Cli.options!['containerRegistryImageVersion']).toStrictEqual('5');
|
||||
expect(Cli.options!['runAsHostUser']).toStrictEqual('true');
|
||||
expect(Cli.options!['chownFilesTo']).toStrictEqual('root:root');
|
||||
});
|
||||
|
||||
it('maps orchestrator-related arguments', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
providerStrategy: 'k8s',
|
||||
awsStackName: 'my-stack',
|
||||
kubeConfig: 'base64config',
|
||||
kubeVolume: 'my-pvc',
|
||||
kubeVolumeSize: '10Gi',
|
||||
kubeStorageClass: 'gp3',
|
||||
containerCpu: '2048',
|
||||
containerMemory: '4096',
|
||||
cacheKey: 'my-cache',
|
||||
watchToEnd: 'false',
|
||||
cloneDepth: '100',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['providerStrategy']).toStrictEqual('k8s');
|
||||
expect(Cli.options!['awsStackName']).toStrictEqual('my-stack');
|
||||
expect(Cli.options!['kubeConfig']).toStrictEqual('base64config');
|
||||
expect(Cli.options!['kubeVolume']).toStrictEqual('my-pvc');
|
||||
expect(Cli.options!['kubeVolumeSize']).toStrictEqual('10Gi');
|
||||
expect(Cli.options!['kubeStorageClass']).toStrictEqual('gp3');
|
||||
expect(Cli.options!['containerCpu']).toStrictEqual('2048');
|
||||
expect(Cli.options!['containerMemory']).toStrictEqual('4096');
|
||||
expect(Cli.options!['cacheKey']).toStrictEqual('my-cache');
|
||||
expect(Cli.options!['watchToEnd']).toStrictEqual('false');
|
||||
expect(Cli.options!['cloneDepth']).toStrictEqual('100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cli.isCliMode integration', () => {
|
||||
it('enables CLI mode after mapping', () => {
|
||||
const cliArguments: CliArguments = { targetPlatform: 'WebGL' };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.isCliMode).toStrictEqual(true);
|
||||
});
|
||||
|
||||
it('is not in CLI mode before mapping', () => {
|
||||
expect(Cli.isCliMode).toStrictEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
|
||||
interface ActivateArguments extends CliArguments {
|
||||
unityVersion?: string;
|
||||
unitySerial?: string;
|
||||
unityLicensingServer?: string;
|
||||
}
|
||||
|
||||
const activateCommand: CommandModule<object, ActivateArguments> = {
|
||||
command: 'activate',
|
||||
describe: 'Verify Unity license configuration',
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.option('unity-version', {
|
||||
alias: 'unityVersion',
|
||||
type: 'string',
|
||||
description: 'Version of Unity to activate',
|
||||
default: 'auto',
|
||||
})
|
||||
.option('unity-licensing-server', {
|
||||
alias: 'unityLicensingServer',
|
||||
type: 'string',
|
||||
description: 'The Unity licensing server address for floating licenses',
|
||||
default: '',
|
||||
})
|
||||
.env('UNITY')
|
||||
.example(
|
||||
'UNITY_SERIAL=XXXX-XXXX-XXXX-XXXX game-ci activate',
|
||||
'Activate Unity using a serial from environment variable',
|
||||
)
|
||||
.example(
|
||||
'game-ci activate --unity-licensing-server http://license-server:8080',
|
||||
'Activate Unity using a floating license server',
|
||||
) as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
const unitySerial = process.env.UNITY_SERIAL;
|
||||
const unityLicense = process.env.UNITY_LICENSE;
|
||||
const licensingServer = cliArguments.unityLicensingServer || process.env.UNITY_LICENSING_SERVER || '';
|
||||
|
||||
if (licensingServer) {
|
||||
core.info(`Activating Unity via licensing server: ${licensingServer}`);
|
||||
core.info('Floating license activation is handled automatically during builds.');
|
||||
core.info('No manual activation step is needed when using a licensing server.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!unitySerial && !unityLicense) {
|
||||
throw new Error(
|
||||
'No Unity license found.\n\n' +
|
||||
'Provide one of the following:\n' +
|
||||
' - UNITY_SERIAL environment variable (professional license)\n' +
|
||||
' - UNITY_LICENSE environment variable (personal license file content)\n' +
|
||||
' - --unity-licensing-server flag (floating license)\n\n' +
|
||||
'For more information, visit: https://game.ci/docs/github/activation',
|
||||
);
|
||||
}
|
||||
|
||||
if (unitySerial) {
|
||||
const maskedSerial = unitySerial.length > 8 ? `${unitySerial.slice(0, 4)}...${unitySerial.slice(-4)}` : '****';
|
||||
core.info(`Unity serial detected: ${maskedSerial}`);
|
||||
core.info('License will be activated automatically when running a build.');
|
||||
} else if (unityLicense) {
|
||||
core.info('Unity license file detected from UNITY_LICENSE environment variable.');
|
||||
core.info('License will be activated automatically when running a build.');
|
||||
}
|
||||
|
||||
core.info('\nActivation verified. You can now run: game-ci build --target-platform <platform>');
|
||||
} catch (error: any) {
|
||||
core.setFailed(`Activation failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default activateCommand;
|
||||
@@ -1,108 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import { BuildParameters, ImageTag } from '../../model';
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
import MacBuilder from '../../model/mac-builder';
|
||||
import Docker from '../../model/docker';
|
||||
import Action from '../../model/action';
|
||||
import PlatformSetup from '../../model/platform-setup';
|
||||
import { withProjectOptions, withDockerOptions, withAndroidOptions } from './shared-options';
|
||||
|
||||
interface BuildArguments extends CliArguments {
|
||||
targetPlatform: string;
|
||||
}
|
||||
|
||||
const buildCommand: CommandModule<object, BuildArguments> = {
|
||||
command: 'build',
|
||||
describe: 'Build a Unity project locally via Docker or native runner',
|
||||
builder: (yargs) => {
|
||||
let y = withProjectOptions(yargs);
|
||||
y = withAndroidOptions(y);
|
||||
y = withDockerOptions(y);
|
||||
|
||||
return y
|
||||
.option('build-profile', {
|
||||
alias: 'buildProfile',
|
||||
type: 'string',
|
||||
description: 'Path to the build profile to activate, relative to the project root',
|
||||
default: '',
|
||||
})
|
||||
.option('versioning', {
|
||||
type: 'string',
|
||||
description: 'The versioning scheme to use when building the project',
|
||||
default: 'Semantic',
|
||||
})
|
||||
.option('version', {
|
||||
type: 'string',
|
||||
description: 'The version, when used with the "Custom" versioning scheme',
|
||||
default: '',
|
||||
})
|
||||
.option('manual-exit', {
|
||||
alias: 'manualExit',
|
||||
type: 'boolean',
|
||||
description: 'Suppresses -quit. Exit your build method using EditorApplication.Exit(0) instead.',
|
||||
default: false,
|
||||
})
|
||||
.option('enable-gpu', {
|
||||
alias: 'enableGpu',
|
||||
type: 'boolean',
|
||||
description: 'Launches unity without specifying -nographics',
|
||||
default: false,
|
||||
})
|
||||
.option('cache-unity-installation-on-mac', {
|
||||
alias: 'cacheUnityInstallationOnMac',
|
||||
type: 'boolean',
|
||||
description: 'Whether to cache the Unity hub and editor installation on MacOS',
|
||||
default: false,
|
||||
})
|
||||
.option('unity-hub-version-on-mac', {
|
||||
alias: 'unityHubVersionOnMac',
|
||||
type: 'string',
|
||||
description: 'The version of Unity Hub to install on MacOS (e.g. 3.4.0). Defaults to latest available on brew.',
|
||||
default: '',
|
||||
})
|
||||
.example('game-ci build --target-platform StandaloneLinux64', 'Build for Linux using auto-detected Unity version')
|
||||
.example(
|
||||
'game-ci build --target-platform Android --unity-version 2022.3.56f1 --build-method MyBuild.Run',
|
||||
'Build for Android with a specific Unity version and build method',
|
||||
) as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
const buildParameters = await BuildParameters.create();
|
||||
const baseImage = new ImageTag(buildParameters);
|
||||
|
||||
core.info(`Building locally for ${buildParameters.targetPlatform}...`);
|
||||
core.info(`Unity version: ${buildParameters.editorVersion}`);
|
||||
core.info(`Project path: ${buildParameters.projectPath}`);
|
||||
|
||||
const actionFolder = Action.actionFolder;
|
||||
await PlatformSetup.setup(buildParameters, actionFolder);
|
||||
|
||||
const exitCode =
|
||||
process.platform === 'darwin'
|
||||
? await MacBuilder.run(actionFolder)
|
||||
: await Docker.run(baseImage.toString(), {
|
||||
workspace: process.cwd(),
|
||||
actionFolder,
|
||||
...buildParameters,
|
||||
});
|
||||
|
||||
core.info(`\nBuild completed with exit code: ${exitCode}`);
|
||||
core.info(`Build version: ${buildParameters.buildVersion}`);
|
||||
core.info(`Build path: ${buildParameters.buildPath}`);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Build failed with exit code ${exitCode}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
core.setFailed(`Build failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default buildCommand;
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import { BuildParameters, ImageTag, Orchestrator } from '../../model';
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
import { withProjectOptions, withOrchestratorOptions } from './shared-options';
|
||||
|
||||
interface OrchestrateArguments extends CliArguments {
|
||||
targetPlatform: string;
|
||||
providerStrategy?: string;
|
||||
}
|
||||
|
||||
const orchestrateCommand: CommandModule<object, OrchestrateArguments> = {
|
||||
command: ['orchestrate', 'o'],
|
||||
describe: 'Run a build via orchestrator providers (AWS, Kubernetes, etc.)',
|
||||
builder: (yargs) => {
|
||||
let y = withProjectOptions(yargs);
|
||||
y = withOrchestratorOptions(y);
|
||||
|
||||
return y
|
||||
.option('versioning', {
|
||||
type: 'string',
|
||||
description: 'The versioning scheme to use',
|
||||
default: 'None',
|
||||
})
|
||||
.example(
|
||||
'game-ci orchestrate --target-platform StandaloneLinux64 --provider-strategy aws',
|
||||
'Build on AWS using the orchestrator',
|
||||
)
|
||||
.example(
|
||||
'game-ci o --target-platform StandaloneLinux64 --provider-strategy k8s --kube-config <base64>',
|
||||
'Build on Kubernetes (short alias)',
|
||||
) as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
const buildParameters = await BuildParameters.create();
|
||||
const baseImage = new ImageTag(buildParameters);
|
||||
|
||||
core.info(`Orchestrating build via ${buildParameters.providerStrategy}...`);
|
||||
core.info(`Target platform: ${buildParameters.targetPlatform}`);
|
||||
core.info(`Unity version: ${buildParameters.editorVersion}`);
|
||||
core.info(`Build GUID: ${buildParameters.buildGuid}`);
|
||||
|
||||
const result = await Orchestrator.run(buildParameters, baseImage.toString());
|
||||
|
||||
core.info(`\nOrchestrated build completed.`);
|
||||
if (result?.BuildResults) {
|
||||
core.info(`Results: ${result.BuildResults}`);
|
||||
} else {
|
||||
core.warning('Build completed but no build results were returned.');
|
||||
}
|
||||
} catch (error: any) {
|
||||
core.setFailed(`Orchestrated build failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default orchestrateCommand;
|
||||
@@ -1,308 +0,0 @@
|
||||
import type { Argv } from 'yargs';
|
||||
|
||||
/**
|
||||
* Shared option groups for CLI commands. Avoids duplicating option
|
||||
* definitions across build, test, and orchestrate commands.
|
||||
*/
|
||||
|
||||
export function withProjectOptions<T>(yargs: Argv<T>) {
|
||||
return yargs
|
||||
.option('target-platform', {
|
||||
alias: 'targetPlatform',
|
||||
type: 'string' as const,
|
||||
description: 'Platform that the build should target',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('unity-version', {
|
||||
alias: 'unityVersion',
|
||||
type: 'string' as const,
|
||||
description: 'Version of Unity to use. Use "auto" to detect from ProjectVersion.txt.',
|
||||
default: 'auto',
|
||||
})
|
||||
.option('project-path', {
|
||||
alias: 'projectPath',
|
||||
type: 'string' as const,
|
||||
description: 'Path to the Unity project',
|
||||
default: '.',
|
||||
})
|
||||
.option('build-name', {
|
||||
alias: 'buildName',
|
||||
type: 'string' as const,
|
||||
description: 'Name of the build',
|
||||
default: '',
|
||||
})
|
||||
.option('builds-path', {
|
||||
alias: 'buildsPath',
|
||||
type: 'string' as const,
|
||||
description: 'Path where the builds should be stored',
|
||||
default: 'build',
|
||||
})
|
||||
.option('build-method', {
|
||||
alias: 'buildMethod',
|
||||
type: 'string' as const,
|
||||
description: 'Path to a Namespace.Class.StaticMethod to run to perform the build',
|
||||
default: '',
|
||||
})
|
||||
.option('custom-parameters', {
|
||||
alias: 'customParameters',
|
||||
type: 'string' as const,
|
||||
description: 'Custom parameters to configure the build',
|
||||
default: '',
|
||||
})
|
||||
.option('custom-image', {
|
||||
alias: 'customImage',
|
||||
type: 'string' as const,
|
||||
description: 'Specific docker image that should be used for building the project',
|
||||
default: '',
|
||||
})
|
||||
.option('git-private-token', {
|
||||
alias: 'gitPrivateToken',
|
||||
type: 'string' as const,
|
||||
description: 'GitHub private token for repository access',
|
||||
default: '',
|
||||
})
|
||||
.option('skip-activation', {
|
||||
alias: 'skipActivation',
|
||||
type: 'string' as const,
|
||||
description: 'Skip Unity activation/deactivation',
|
||||
default: 'false',
|
||||
})
|
||||
.option('unity-licensing-server', {
|
||||
alias: 'unityLicensingServer',
|
||||
type: 'string' as const,
|
||||
description: 'The Unity licensing server address',
|
||||
default: '',
|
||||
})
|
||||
.option('container-registry-repository', {
|
||||
alias: 'containerRegistryRepository',
|
||||
type: 'string' as const,
|
||||
description: 'Container registry and repository to pull image from. Only applicable if customImage is not set.',
|
||||
default: 'unityci/editor',
|
||||
})
|
||||
.option('container-registry-image-version', {
|
||||
alias: 'containerRegistryImageVersion',
|
||||
type: 'string' as const,
|
||||
description: 'Container registry image version. Only applicable if customImage is not set.',
|
||||
default: '3',
|
||||
});
|
||||
}
|
||||
|
||||
export function withDockerOptions<T>(yargs: Argv<T>) {
|
||||
return yargs
|
||||
.option('docker-cpu-limit', {
|
||||
alias: 'dockerCpuLimit',
|
||||
type: 'string' as const,
|
||||
description: 'Number of CPU cores to assign the docker container',
|
||||
default: '',
|
||||
})
|
||||
.option('docker-memory-limit', {
|
||||
alias: 'dockerMemoryLimit',
|
||||
type: 'string' as const,
|
||||
description: 'Amount of memory to assign the docker container (e.g. 512m, 4g)',
|
||||
default: '',
|
||||
})
|
||||
.option('docker-workspace-path', {
|
||||
alias: 'dockerWorkspacePath',
|
||||
type: 'string' as const,
|
||||
description: 'The path to mount the workspace inside the docker container',
|
||||
default: '/github/workspace',
|
||||
})
|
||||
.option('docker-isolation-mode', {
|
||||
alias: 'dockerIsolationMode',
|
||||
type: 'string' as const,
|
||||
description:
|
||||
'Isolation mode to use for the docker container (process, hyperv, or default). Only applicable on Windows.',
|
||||
default: 'default',
|
||||
})
|
||||
.option('run-as-host-user', {
|
||||
alias: 'runAsHostUser',
|
||||
type: 'string' as const,
|
||||
description: 'Whether to run as a user that matches the host system',
|
||||
default: 'false',
|
||||
})
|
||||
.option('chown-files-to', {
|
||||
alias: 'chownFilesTo',
|
||||
type: 'string' as const,
|
||||
description: 'User and optionally group to give ownership of build artifacts',
|
||||
default: '',
|
||||
})
|
||||
.option('ssh-agent', {
|
||||
alias: 'sshAgent',
|
||||
type: 'string' as const,
|
||||
description: 'SSH Agent path to forward to the container',
|
||||
default: '',
|
||||
})
|
||||
.option('ssh-public-keys-directory-path', {
|
||||
alias: 'sshPublicKeysDirectoryPath',
|
||||
type: 'string' as const,
|
||||
description: 'Path to a directory containing SSH public keys to forward to the container',
|
||||
default: '',
|
||||
});
|
||||
}
|
||||
|
||||
export function withAndroidOptions<T>(yargs: Argv<T>) {
|
||||
return yargs
|
||||
.option('android-version-code', {
|
||||
alias: 'androidVersionCode',
|
||||
type: 'string' as const,
|
||||
description: 'The android versionCode',
|
||||
default: '',
|
||||
})
|
||||
.option('android-export-type', {
|
||||
alias: 'androidExportType',
|
||||
type: 'string' as const,
|
||||
description: 'The android export type (androidPackage, androidAppBundle, androidStudioProject)',
|
||||
default: 'androidPackage',
|
||||
})
|
||||
.option('android-keystore-name', {
|
||||
alias: 'androidKeystoreName',
|
||||
type: 'string' as const,
|
||||
description: 'The android keystoreName',
|
||||
default: '',
|
||||
})
|
||||
.option('android-keystore-base64', {
|
||||
alias: 'androidKeystoreBase64',
|
||||
type: 'string' as const,
|
||||
description: 'The base64 contents of the android keystore file',
|
||||
default: '',
|
||||
})
|
||||
.option('android-keystore-pass', {
|
||||
alias: 'androidKeystorePass',
|
||||
type: 'string' as const,
|
||||
description: 'The android keystorePass',
|
||||
default: '',
|
||||
})
|
||||
.option('android-keyalias-name', {
|
||||
alias: 'androidKeyaliasName',
|
||||
type: 'string' as const,
|
||||
description: 'The android keyaliasName',
|
||||
default: '',
|
||||
})
|
||||
.option('android-keyalias-pass', {
|
||||
alias: 'androidKeyaliasPass',
|
||||
type: 'string' as const,
|
||||
description: 'The android keyaliasPass',
|
||||
default: '',
|
||||
})
|
||||
.option('android-target-sdk-version', {
|
||||
alias: 'androidTargetSdkVersion',
|
||||
type: 'string' as const,
|
||||
description: 'The android target API level',
|
||||
default: '',
|
||||
})
|
||||
.option('android-symbol-type', {
|
||||
alias: 'androidSymbolType',
|
||||
type: 'string' as const,
|
||||
description: 'The android symbol type to export (none, public, debugging)',
|
||||
default: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
export function withOrchestratorOptions<T>(yargs: Argv<T>) {
|
||||
return yargs
|
||||
.option('provider-strategy', {
|
||||
alias: 'providerStrategy',
|
||||
type: 'string' as const,
|
||||
description: 'Orchestrator provider: aws, k8s, local-docker, local-system',
|
||||
default: 'aws',
|
||||
})
|
||||
.option('aws-stack-name', {
|
||||
alias: 'awsStackName',
|
||||
type: 'string' as const,
|
||||
description: 'The Cloud Formation stack name (AWS provider)',
|
||||
default: 'game-ci',
|
||||
})
|
||||
.option('kube-config', {
|
||||
alias: 'kubeConfig',
|
||||
type: 'string' as const,
|
||||
description: 'Base64 encoded Kubernetes config (K8s provider)',
|
||||
default: '',
|
||||
})
|
||||
.option('kube-volume', {
|
||||
alias: 'kubeVolume',
|
||||
type: 'string' as const,
|
||||
description: 'Persistent Volume Claim name for Unity build (K8s provider)',
|
||||
default: '',
|
||||
})
|
||||
.option('kube-volume-size', {
|
||||
alias: 'kubeVolumeSize',
|
||||
type: 'string' as const,
|
||||
description: 'Disc space for Kubernetes Persistent Volume',
|
||||
default: '5Gi',
|
||||
})
|
||||
.option('kube-storage-class', {
|
||||
alias: 'kubeStorageClass',
|
||||
type: 'string' as const,
|
||||
description: 'Kubernetes storage class to use for orchestrator jobs. Leave empty to install rook cluster.',
|
||||
default: '',
|
||||
})
|
||||
.option('container-cpu', {
|
||||
alias: 'containerCpu',
|
||||
type: 'string' as const,
|
||||
description: 'CPU allocation for remote build container',
|
||||
default: '1024',
|
||||
})
|
||||
.option('container-memory', {
|
||||
alias: 'containerMemory',
|
||||
type: 'string' as const,
|
||||
description: 'Memory allocation for remote build container',
|
||||
default: '3072',
|
||||
})
|
||||
.option('cache-key', {
|
||||
alias: 'cacheKey',
|
||||
type: 'string' as const,
|
||||
description: 'Cache key to indicate bucket for cache',
|
||||
default: '',
|
||||
})
|
||||
.option('allow-dirty-build', {
|
||||
alias: 'allowDirtyBuild',
|
||||
type: 'boolean' as const,
|
||||
description: 'Allow builds from dirty branches',
|
||||
default: false,
|
||||
})
|
||||
.option('watch-to-end', {
|
||||
alias: 'watchToEnd',
|
||||
type: 'string' as const,
|
||||
description: 'Whether to watch the build to completion',
|
||||
default: 'true',
|
||||
})
|
||||
.option('clone-depth', {
|
||||
alias: 'cloneDepth',
|
||||
type: 'string' as const,
|
||||
description: 'Git clone depth (0 for full clone)',
|
||||
default: '50',
|
||||
})
|
||||
.option('read-input-from-override-list', {
|
||||
alias: 'readInputFromOverrideList',
|
||||
type: 'string' as const,
|
||||
description: 'Comma separated list of input value names to read from the input override command',
|
||||
default: '',
|
||||
})
|
||||
.option('read-input-override-command', {
|
||||
alias: 'readInputOverrideCommand',
|
||||
type: 'string' as const,
|
||||
description: 'Command to execute to pull input from an external source (e.g. cloud provider secret managers)',
|
||||
default: '',
|
||||
})
|
||||
.option('post-build-steps', {
|
||||
alias: 'postBuildSteps',
|
||||
type: 'string' as const,
|
||||
description:
|
||||
'Post build job in yaml format with the keys image, secrets (name, value object array), command string',
|
||||
default: '',
|
||||
})
|
||||
.option('pre-build-steps', {
|
||||
alias: 'preBuildSteps',
|
||||
type: 'string' as const,
|
||||
description:
|
||||
'Pre build job after repository setup but before the build job (yaml format with keys image, secrets, command)',
|
||||
default: '',
|
||||
})
|
||||
.option('custom-job', {
|
||||
alias: 'customJob',
|
||||
type: 'string' as const,
|
||||
description:
|
||||
'Custom job instead of the standard build automation (yaml format with keys image, secrets, command)',
|
||||
default: '',
|
||||
});
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import UnityVersioning from '../../model/unity-versioning';
|
||||
|
||||
const statusCommand: CommandModule = {
|
||||
command: 'status',
|
||||
describe: 'Show project info, environment, and cache status',
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.option('project-path', {
|
||||
alias: 'projectPath',
|
||||
type: 'string',
|
||||
description: 'Path to the Unity project',
|
||||
default: '.',
|
||||
})
|
||||
.option('cache-dir', {
|
||||
alias: 'cacheDir',
|
||||
type: 'string',
|
||||
description: 'Path to an additional cache directory to inspect',
|
||||
default: '',
|
||||
});
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
const projectPath = (cliArguments.projectPath as string) || '.';
|
||||
const cacheDirectory = cliArguments.cacheDir as string;
|
||||
|
||||
core.info('game-ci Workspace Status');
|
||||
core.info('========================\n');
|
||||
|
||||
// Project detection
|
||||
const projectVersionPath = path.join(projectPath, 'ProjectSettings', 'ProjectVersion.txt');
|
||||
const hasProject = fs.existsSync(projectVersionPath);
|
||||
|
||||
core.info(`Project Path: ${path.resolve(projectPath)}`);
|
||||
core.info(`Unity Project Found: ${hasProject ? 'Yes' : 'No'}`);
|
||||
|
||||
if (hasProject) {
|
||||
try {
|
||||
const unityVersion = UnityVersioning.determineUnityVersion(projectPath, 'auto');
|
||||
core.info(`Unity Version: ${unityVersion}`);
|
||||
} catch {
|
||||
core.info(`Unity Version: Unable to detect`);
|
||||
}
|
||||
|
||||
// Library cache status
|
||||
const libraryPath = path.join(projectPath, 'Library');
|
||||
if (fs.existsSync(libraryPath)) {
|
||||
const stats = fs.statSync(libraryPath);
|
||||
core.info(`Library Cache: Present (modified ${stats.mtime.toISOString()})`);
|
||||
|
||||
const keyDirectories = ['PackageCache', 'ScriptAssemblies', 'ShaderCache', 'Bee'];
|
||||
for (const directory of keyDirectories) {
|
||||
const directoryPath = path.join(libraryPath, directory);
|
||||
if (fs.existsSync(directoryPath)) {
|
||||
const directoryStats = fs.statSync(directoryPath);
|
||||
core.info(` ${directory}/: exists (modified ${directoryStats.mtime.toISOString()})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
core.info(`Library Cache: Not present (clean build required)`);
|
||||
}
|
||||
|
||||
// Cache archive detection
|
||||
if (cacheDirectory && fs.existsSync(cacheDirectory)) {
|
||||
core.info(`\nCache Archives (${cacheDirectory}):`);
|
||||
const cacheFiles = fs.readdirSync(cacheDirectory).filter((f) => f.endsWith('.tar') || f.endsWith('.tar.lz4'));
|
||||
if (cacheFiles.length > 0) {
|
||||
for (const file of cacheFiles) {
|
||||
const filePath = path.join(cacheDirectory, file);
|
||||
const fileStats = fs.statSync(filePath);
|
||||
const sizeMegabytes = (fileStats.size / (1024 * 1024)).toFixed(1);
|
||||
core.info(` - ${file} (${sizeMegabytes} MB, ${fileStats.mtime.toISOString()})`);
|
||||
}
|
||||
} else {
|
||||
core.info(' No cache archives found.');
|
||||
}
|
||||
}
|
||||
|
||||
// Build output detection
|
||||
const buildsPath = path.join(projectPath, '..', 'build');
|
||||
if (fs.existsSync(buildsPath)) {
|
||||
const builds = fs.readdirSync(buildsPath);
|
||||
if (builds.length > 0) {
|
||||
core.info(`\nBuild Outputs (${buildsPath}):`);
|
||||
for (const build of builds) {
|
||||
const buildPath = path.join(buildsPath, build);
|
||||
const buildStats = fs.statSync(buildPath);
|
||||
core.info(` - ${build} (${buildStats.isDirectory() ? 'dir' : 'file'}, ${buildStats.mtime.toISOString()})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Environment
|
||||
core.info('\nEnvironment:');
|
||||
core.info(` Platform: ${process.platform}`);
|
||||
core.info(` Node.js: ${process.version}`);
|
||||
core.info(` UNITY_SERIAL: ${process.env.UNITY_SERIAL ? 'Set' : 'Not set'}`);
|
||||
core.info(` UNITY_LICENSE: ${process.env.UNITY_LICENSE ? 'Set' : 'Not set'}`);
|
||||
core.info(` UNITY_EMAIL: ${process.env.UNITY_EMAIL ? 'Set' : 'Not set'}`);
|
||||
core.info(` UNITY_PASSWORD: ${process.env.UNITY_PASSWORD ? 'Set' : 'Not set'}`);
|
||||
|
||||
// Docker availability
|
||||
core.info(`\nDocker: Checking...`);
|
||||
try {
|
||||
const { execSync } = await import('node:child_process');
|
||||
const dockerVersion = execSync('docker --version', { encoding: 'utf8' }).trim();
|
||||
core.info(` ${dockerVersion}`);
|
||||
} catch {
|
||||
core.info(` Docker not found or not accessible`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default statusCommand;
|
||||
@@ -1,126 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import { BuildParameters, ImageTag } from '../../model';
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
import Docker from '../../model/docker';
|
||||
import Action from '../../model/action';
|
||||
import PlatformSetup from '../../model/platform-setup';
|
||||
import { withProjectOptions, withDockerOptions } from './shared-options';
|
||||
|
||||
interface TestArguments extends CliArguments {
|
||||
targetPlatform: string;
|
||||
testMode?: string;
|
||||
}
|
||||
|
||||
const testCommand: CommandModule<object, TestArguments> = {
|
||||
command: ['test', 't'],
|
||||
describe: 'Run tests for a Unity project',
|
||||
builder: (yargs) => {
|
||||
let y = withProjectOptions(yargs);
|
||||
y = withDockerOptions(y);
|
||||
|
||||
return y
|
||||
.option('test-mode', {
|
||||
alias: 'testMode',
|
||||
type: 'string',
|
||||
description: 'The mode to run tests in (EditMode, PlayMode, or All)',
|
||||
default: 'All',
|
||||
choices: ['EditMode', 'PlayMode', 'All'],
|
||||
})
|
||||
.option('test-results-path', {
|
||||
alias: 'testResultsPath',
|
||||
type: 'string',
|
||||
description: 'Path where test results XML should be stored',
|
||||
default: 'test-results',
|
||||
})
|
||||
.option('test-category', {
|
||||
alias: 'testCategory',
|
||||
type: 'string',
|
||||
description: 'Only run tests in the given category (semicolon-separated)',
|
||||
default: '',
|
||||
})
|
||||
.option('test-filter', {
|
||||
alias: 'testFilter',
|
||||
type: 'string',
|
||||
description: 'Only run tests that match the filter (semicolon-separated)',
|
||||
default: '',
|
||||
})
|
||||
.option('coverage-options', {
|
||||
alias: 'coverageOptions',
|
||||
type: 'string',
|
||||
description: 'Options for code coverage (e.g. assemblyFilters, pathFilters)',
|
||||
default: '',
|
||||
})
|
||||
.option('enable-code-coverage', {
|
||||
alias: 'enableCodeCoverage',
|
||||
type: 'boolean',
|
||||
description: 'Enable code coverage when running tests',
|
||||
default: false,
|
||||
})
|
||||
.option('versioning', {
|
||||
type: 'string',
|
||||
description: 'The versioning scheme to use',
|
||||
default: 'None',
|
||||
})
|
||||
.option('cache-unity-installation-on-mac', {
|
||||
alias: 'cacheUnityInstallationOnMac',
|
||||
type: 'boolean',
|
||||
description: 'Whether to cache the Unity hub and editor installation on MacOS',
|
||||
default: false,
|
||||
})
|
||||
.option('unity-hub-version-on-mac', {
|
||||
alias: 'unityHubVersionOnMac',
|
||||
type: 'string',
|
||||
description: 'The version of Unity Hub to install on MacOS (e.g. 3.4.0). Defaults to latest available on brew.',
|
||||
default: '',
|
||||
})
|
||||
.example('game-ci test --target-platform StandaloneLinux64', 'Run all tests for Linux platform')
|
||||
.example(
|
||||
'game-ci t --target-platform StandaloneLinux64 --test-mode EditMode',
|
||||
'Run only EditMode tests (short alias)',
|
||||
)
|
||||
.example(
|
||||
'game-ci test --target-platform StandaloneLinux64 --enable-code-coverage',
|
||||
'Run tests with code coverage',
|
||||
) as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
// Map test-specific flags into the input system
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
const buildParameters = await BuildParameters.create();
|
||||
const baseImage = new ImageTag(buildParameters);
|
||||
|
||||
const testMode = cliArguments.testMode || 'All';
|
||||
|
||||
core.info(`Running Unity tests (${testMode})...`);
|
||||
core.info(`Target platform: ${buildParameters.targetPlatform}`);
|
||||
core.info(`Unity version: ${buildParameters.editorVersion}`);
|
||||
core.info(`Project path: ${buildParameters.projectPath}`);
|
||||
|
||||
const actionFolder = Action.actionFolder;
|
||||
await PlatformSetup.setup(buildParameters, actionFolder);
|
||||
|
||||
const exitCode = await Docker.run(baseImage.toString(), {
|
||||
workspace: process.cwd(),
|
||||
actionFolder,
|
||||
...buildParameters,
|
||||
});
|
||||
|
||||
const resultsPath = cliArguments.testResultsPath || 'test-results';
|
||||
core.info(`\nTests completed with exit code: ${exitCode}`);
|
||||
core.info(`Test results: ${resultsPath}`);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Tests failed with exit code ${exitCode}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
core.setFailed(`Tests failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default testCommand;
|
||||
@@ -1,387 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const REPO = 'game-ci/unity-builder';
|
||||
|
||||
interface GitHubRelease {
|
||||
// eslint-disable-next-line camelcase
|
||||
tag_name: string;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface UpdateArguments {
|
||||
force?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches JSON from a URL via HTTPS, following redirects.
|
||||
*/
|
||||
function fetchJson(url: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const get = (targetUrl: string, redirectCount: number) => {
|
||||
if (redirectCount > 5) {
|
||||
reject(new Error('Too many redirects'));
|
||||
|
||||
return;
|
||||
}
|
||||
https
|
||||
.get(
|
||||
targetUrl,
|
||||
{
|
||||
headers: { 'User-Agent': 'game-ci-cli', Accept: 'application/json' },
|
||||
},
|
||||
(response) => {
|
||||
if (
|
||||
response.statusCode &&
|
||||
response.statusCode >= 300 &&
|
||||
response.statusCode < 400 &&
|
||||
response.headers.location
|
||||
) {
|
||||
get(response.headers.location, redirectCount + 1);
|
||||
|
||||
return;
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${response.statusCode} from ${targetUrl}`));
|
||||
|
||||
return;
|
||||
}
|
||||
let data = '';
|
||||
response.on('data', (chunk) => (data += chunk));
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
reject(new Error('Invalid JSON response'));
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
.on('error', reject);
|
||||
};
|
||||
get(url, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a file from a URL, following redirects. Returns the file content as a Buffer.
|
||||
*/
|
||||
function downloadFile(url: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const get = (targetUrl: string, redirectCount: number) => {
|
||||
if (redirectCount > 10) {
|
||||
reject(new Error('Too many redirects'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = targetUrl.startsWith('https') ? https : http;
|
||||
protocol
|
||||
.get(targetUrl, { headers: { 'User-Agent': 'game-ci-cli' } }, (response) => {
|
||||
if (
|
||||
response.statusCode &&
|
||||
response.statusCode >= 300 &&
|
||||
response.statusCode < 400 &&
|
||||
response.headers.location
|
||||
) {
|
||||
get(response.headers.location, redirectCount + 1);
|
||||
|
||||
return;
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${response.statusCode} downloading ${targetUrl}`));
|
||||
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
response.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
})
|
||||
.on('error', reject);
|
||||
};
|
||||
get(url, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current version from package.json or the compiled binary.
|
||||
*/
|
||||
function getCurrentVersion(): string {
|
||||
// Try reading from package.json at various relative locations
|
||||
const candidates = [
|
||||
path.join(__dirname, '..', '..', '..', 'package.json'),
|
||||
path.join(__dirname, '..', '..', 'package.json'),
|
||||
path.join(process.cwd(), 'package.json'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
const packageData = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
||||
if (packageData.version) {
|
||||
return packageData.version;
|
||||
}
|
||||
} catch {
|
||||
// Continue to next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the correct asset name for the current platform/architecture.
|
||||
*/
|
||||
function getAssetName(): string {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
let osPart: string;
|
||||
switch (platform) {
|
||||
case 'linux':
|
||||
osPart = 'linux';
|
||||
break;
|
||||
case 'darwin':
|
||||
osPart = 'macos';
|
||||
break;
|
||||
case 'win32':
|
||||
osPart = 'windows';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
let archPart: string;
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
archPart = 'x64';
|
||||
break;
|
||||
case 'arm64':
|
||||
archPart = 'arm64';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported architecture: ${arch}`);
|
||||
}
|
||||
|
||||
const assetBaseName = `game-ci-${osPart}-${archPart}`;
|
||||
|
||||
return osPart === 'windows' ? `${assetBaseName}.exe` : assetBaseName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the path to the currently running executable.
|
||||
* For standalone binaries (pkg), process.execPath points to the binary itself.
|
||||
* For Node.js execution, we return undefined since self-update does not apply.
|
||||
*/
|
||||
function getExecutablePath(): string | undefined {
|
||||
// When running as a pkg binary, process.pkg is defined
|
||||
if ((process as any).pkg) {
|
||||
return process.execPath;
|
||||
}
|
||||
|
||||
// When running via Node.js, check if there is a standalone binary in the typical install location
|
||||
const installDirectory = process.env.GAME_CI_INSTALL || path.join(os.homedir(), '.game-ci', 'bin');
|
||||
const binaryName = process.platform === 'win32' ? 'game-ci.exe' : 'game-ci';
|
||||
const installedPath = path.join(installDirectory, binaryName);
|
||||
|
||||
if (fs.existsSync(installedPath)) {
|
||||
return installedPath;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips leading 'v' from a version string and splits into numeric parts.
|
||||
*/
|
||||
function parseVersionParts(version: string): number[] {
|
||||
return version
|
||||
.replace(/^v/, '')
|
||||
.split('.')
|
||||
.map((part) => Number(part));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two semver strings. Returns:
|
||||
* -1 if a < b
|
||||
* 0 if a == b
|
||||
* 1 if a > b
|
||||
*/
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const partsA = parseVersionParts(a);
|
||||
const partsB = parseVersionParts(b);
|
||||
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const x = partsA[index] || 0;
|
||||
const y = partsB[index] || 0;
|
||||
if (x < y) return -1;
|
||||
if (x > y) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
const updateCommand: CommandModule<object, UpdateArguments> = {
|
||||
command: 'update',
|
||||
describe: 'Update game-ci to the latest version',
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.option('force', {
|
||||
alias: 'f',
|
||||
type: 'boolean',
|
||||
description: 'Force update even if already on latest version',
|
||||
default: false,
|
||||
})
|
||||
.option('version', {
|
||||
type: 'string',
|
||||
description: 'Update to a specific version (e.g., v2.0.0)',
|
||||
default: '',
|
||||
})
|
||||
.example('game-ci update', 'Update to the latest version')
|
||||
.example('game-ci update --version v2.1.0', 'Update to a specific version')
|
||||
.example('game-ci update --force', 'Force reinstall of the current version') as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
const currentVersion = getCurrentVersion();
|
||||
core.info(`Current version: v${currentVersion}`);
|
||||
core.info(`Platform: ${process.platform} ${process.arch}`);
|
||||
core.info('');
|
||||
|
||||
// Fetch release info
|
||||
let release: GitHubRelease;
|
||||
const targetVersion = cliArguments.version as string;
|
||||
|
||||
if (targetVersion) {
|
||||
const tag = targetVersion.startsWith('v') ? targetVersion : `v${targetVersion}`;
|
||||
core.info(`Fetching release ${tag}...`);
|
||||
release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`);
|
||||
} else {
|
||||
core.info('Checking for updates...');
|
||||
release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
|
||||
}
|
||||
|
||||
const latestVersion = release.tag_name;
|
||||
core.info(`Latest version: ${latestVersion}`);
|
||||
core.info('');
|
||||
|
||||
// Compare versions
|
||||
const comparison = compareSemver(currentVersion, latestVersion);
|
||||
if (comparison >= 0 && !cliArguments.force) {
|
||||
core.info('You are already on the latest version. Use --force to reinstall.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison > 0 && !targetVersion) {
|
||||
core.info(`Current version (v${currentVersion}) is newer than latest release (${latestVersion}).`);
|
||||
core.info('Use --force to downgrade, or --version to target a specific release.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the correct asset
|
||||
const assetName = getAssetName();
|
||||
const asset = release.assets.find((a) => a.name === assetName);
|
||||
|
||||
if (!asset) {
|
||||
const available = release.assets.map((a) => a.name).join(', ');
|
||||
throw new Error(
|
||||
`No binary found for ${process.platform}-${process.arch} (looking for ${assetName}).\nAvailable assets: ${available}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sizeMb = (asset.size / (1024 * 1024)).toFixed(1);
|
||||
core.info(`Downloading ${assetName} (${sizeMb} MB)...`);
|
||||
|
||||
// Download the new binary
|
||||
const binaryData = await downloadFile(asset.browser_download_url);
|
||||
|
||||
// Determine where to write the updated binary
|
||||
const executablePath = getExecutablePath();
|
||||
|
||||
if (!executablePath) {
|
||||
core.info('');
|
||||
core.info('game-ci is running via Node.js (not as a standalone binary).');
|
||||
core.info('To update the npm package, run:');
|
||||
core.info(' npm install -g unity-builder@latest');
|
||||
core.info('');
|
||||
core.info('To install the standalone binary instead:');
|
||||
core.info(' curl -fsSL https://raw.githubusercontent.com/game-ci/unity-builder/main/install.sh | sh');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Write the new binary.
|
||||
// On Windows, we cannot overwrite a running executable directly.
|
||||
// Write to a temporary file, then rename.
|
||||
const temporaryPath = `${executablePath}.update`;
|
||||
const backupPath = `${executablePath}.backup`;
|
||||
|
||||
fs.writeFileSync(temporaryPath, binaryData);
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(temporaryPath, 0o755);
|
||||
}
|
||||
|
||||
// Verify the downloaded binary
|
||||
try {
|
||||
const output = execFileSync(temporaryPath, ['version'], { encoding: 'utf8', timeout: 10_000 });
|
||||
core.info(`Verified new binary: ${output.trim().split('\n')[0]}`);
|
||||
} catch (verifyError: any) {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
throw new Error(`Downloaded binary failed verification: ${verifyError.message}`);
|
||||
}
|
||||
|
||||
// Replace the current binary
|
||||
try {
|
||||
// Backup current
|
||||
if (fs.existsSync(backupPath)) {
|
||||
fs.unlinkSync(backupPath);
|
||||
}
|
||||
fs.renameSync(executablePath, backupPath);
|
||||
fs.renameSync(temporaryPath, executablePath);
|
||||
|
||||
// Clean up backup
|
||||
try {
|
||||
fs.unlinkSync(backupPath);
|
||||
} catch {
|
||||
// On Windows the backup may be locked; that is fine
|
||||
}
|
||||
} catch (replaceError: any) {
|
||||
// Attempt to restore from backup
|
||||
if (fs.existsSync(backupPath) && !fs.existsSync(executablePath)) {
|
||||
fs.renameSync(backupPath, executablePath);
|
||||
}
|
||||
|
||||
// Clean up temporary file
|
||||
if (fs.existsSync(temporaryPath)) {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
}
|
||||
throw new Error(`Failed to replace binary: ${replaceError.message}`);
|
||||
}
|
||||
|
||||
core.info('');
|
||||
core.info(`Successfully updated game-ci to ${latestVersion}`);
|
||||
} catch (error: any) {
|
||||
core.error(`Update failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default updateCommand;
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const versionCommand: CommandModule = {
|
||||
command: 'version',
|
||||
describe: 'Show version info',
|
||||
builder: {},
|
||||
handler: async () => {
|
||||
try {
|
||||
// Read version from package.json
|
||||
let packageJsonPath = path.join(__dirname, '..', '..', '..', 'package.json');
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
|
||||
}
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
packageJsonPath = path.join(process.cwd(), 'package.json');
|
||||
}
|
||||
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const packageData = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
core.info(`game-ci (unity-builder) v${packageData.version}`);
|
||||
core.info(`Node.js ${process.version}`);
|
||||
core.info(`Platform: ${process.platform} ${process.arch}`);
|
||||
} else {
|
||||
core.info('game-ci (unity-builder)');
|
||||
core.info('Version information unavailable');
|
||||
}
|
||||
} catch (error: any) {
|
||||
core.info('game-ci (unity-builder)');
|
||||
core.error(`Could not read version: ${error.message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default versionCommand;
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Cli } from '../model/cli/cli';
|
||||
import GitHub from '../model/github';
|
||||
|
||||
/**
|
||||
* Maps CLI arguments (kebab-case flags) to the Input/OrchestratorOptions
|
||||
* interface used by the action. This bridges the gap between user-friendly
|
||||
* CLI flags and the camelCase environment/input system unity-builder expects.
|
||||
*
|
||||
* The existing Input class already queries Cli.options, environment variables,
|
||||
* and GitHub Action inputs in priority order. We populate Cli.options so that
|
||||
* the rest of the codebase works unchanged.
|
||||
*/
|
||||
export interface CliArguments {
|
||||
targetPlatform?: string;
|
||||
unityVersion?: string;
|
||||
projectPath?: string;
|
||||
buildProfile?: string;
|
||||
buildName?: string;
|
||||
buildsPath?: string;
|
||||
buildMethod?: string;
|
||||
customParameters?: string;
|
||||
versioning?: string;
|
||||
version?: string;
|
||||
customImage?: string;
|
||||
manualExit?: boolean;
|
||||
enableGpu?: boolean;
|
||||
|
||||
androidVersionCode?: string;
|
||||
androidExportType?: string;
|
||||
androidKeystoreName?: string;
|
||||
androidKeystoreBase64?: string;
|
||||
androidKeystorePass?: string;
|
||||
androidKeyaliasName?: string;
|
||||
androidKeyaliasPass?: string;
|
||||
androidTargetSdkVersion?: string;
|
||||
androidSymbolType?: string;
|
||||
|
||||
dockerCpuLimit?: string;
|
||||
dockerMemoryLimit?: string;
|
||||
dockerIsolationMode?: string;
|
||||
dockerWorkspacePath?: string;
|
||||
containerRegistryRepository?: string;
|
||||
containerRegistryImageVersion?: string;
|
||||
runAsHostUser?: string;
|
||||
chownFilesTo?: string;
|
||||
|
||||
sshAgent?: string;
|
||||
sshPublicKeysDirectoryPath?: string;
|
||||
gitPrivateToken?: string;
|
||||
|
||||
providerStrategy?: string;
|
||||
awsStackName?: string;
|
||||
kubeConfig?: string;
|
||||
kubeVolume?: string;
|
||||
kubeVolumeSize?: string;
|
||||
kubeStorageClass?: string;
|
||||
containerCpu?: string;
|
||||
containerMemory?: string;
|
||||
cacheKey?: string;
|
||||
watchToEnd?: string;
|
||||
allowDirtyBuild?: boolean;
|
||||
skipActivation?: string;
|
||||
cloneDepth?: string;
|
||||
|
||||
readInputFromOverrideList?: string;
|
||||
readInputOverrideCommand?: string;
|
||||
postBuildSteps?: string;
|
||||
preBuildSteps?: string;
|
||||
customJob?: string;
|
||||
|
||||
unityLicensingServer?: string;
|
||||
|
||||
cacheUnityInstallationOnMac?: boolean;
|
||||
unityHubVersionOnMac?: string;
|
||||
|
||||
testMode?: string;
|
||||
testResultsPath?: string;
|
||||
testCategory?: string;
|
||||
testFilter?: string;
|
||||
coverageOptions?: string;
|
||||
enableCodeCoverage?: boolean;
|
||||
|
||||
mode?: string;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts kebab-case CLI flags to camelCase keys matching the Input class
|
||||
* property names, then injects them into Cli.options so the existing
|
||||
* Input.getInput() / OrchestratorOptions.getInput() chain picks them up.
|
||||
*/
|
||||
export function mapCliArgumentsToInput(cliArguments: CliArguments): void {
|
||||
// Disable GitHub Actions input reading when in CLI mode
|
||||
GitHub.githubInputEnabled = false;
|
||||
|
||||
// The existing Cli.options mechanism is used by Input.getInput() to query
|
||||
// CLI-provided values. We set it directly.
|
||||
const mapped: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(cliArguments)) {
|
||||
if (value !== undefined && key !== '_' && key !== '$0') {
|
||||
mapped[key] = typeof value === 'boolean' ? String(value) : value;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure mode is set so Cli.isCliMode returns true
|
||||
if (!mapped['mode']) {
|
||||
mapped['mode'] = 'cli';
|
||||
}
|
||||
|
||||
Cli.options = mapped;
|
||||
}
|
||||
606
src/index-enterprise-features.test.ts
Normal file
606
src/index-enterprise-features.test.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
112
src/index.ts
112
src/index.ts
@@ -1,4 +1,5 @@
|
||||
import * as core from '@actions/core';
|
||||
import path from 'node:path';
|
||||
import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output } from './model';
|
||||
import { Cli } from './model/cli/cli';
|
||||
import MacBuilder from './model/mac-builder';
|
||||
@@ -23,6 +24,94 @@ async function runMain() {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
await PlatformSetup.setup(buildParameters, actionFolder);
|
||||
exitCode =
|
||||
process.platform === 'darwin'
|
||||
@@ -32,6 +121,29 @@ async function runMain() {
|
||||
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`);
|
||||
}
|
||||
} else {
|
||||
await Orchestrator.run(buildParameters, baseImage.toString());
|
||||
exitCode = 0;
|
||||
|
||||
@@ -106,6 +106,27 @@ 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;
|
||||
public middleware!: string;
|
||||
public middlewareFiles!: string[];
|
||||
|
||||
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
|
||||
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
|
||||
@@ -242,6 +263,27 @@ 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,
|
||||
middleware: OrchestratorOptions.middleware,
|
||||
middlewareFiles: OrchestratorOptions.middlewareFiles,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ 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;
|
||||
@@ -53,6 +55,11 @@ 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();
|
||||
|
||||
@@ -155,8 +162,8 @@ export class Cli {
|
||||
return result.map((x) => x.Name);
|
||||
}
|
||||
|
||||
@CliFunction(`list-workflow`, `lists running workflows`)
|
||||
public static async ListWorkflow(): Promise<string[]> {
|
||||
@CliFunction(`list-worfklow`, `lists running workflows`)
|
||||
public static async ListWorfklow(): Promise<string[]> {
|
||||
const buildParameter = await BuildParameters.create();
|
||||
|
||||
await Orchestrator.setup(buildParameter);
|
||||
@@ -172,4 +179,26 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
509
src/model/enterprise-inputs.test.ts
Normal file
509
src/model/enterprise-inputs.test.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* 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('');
|
||||
});
|
||||
});
|
||||
@@ -282,6 +282,90 @@ class Input {
|
||||
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
|
||||
}
|
||||
|
||||
static get submoduleProfilePath(): string {
|
||||
return Input.getInput('submoduleProfilePath') ?? '';
|
||||
}
|
||||
|
||||
static get submoduleVariantPath(): string {
|
||||
return Input.getInput('submoduleVariantPath') ?? '';
|
||||
}
|
||||
|
||||
static get submoduleToken(): string {
|
||||
return Input.getInput('submoduleToken') ?? '';
|
||||
}
|
||||
|
||||
static get localCacheEnabled(): boolean {
|
||||
return (Input.getInput('localCacheEnabled') ?? 'false') === 'true';
|
||||
}
|
||||
|
||||
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') ?? '';
|
||||
}
|
||||
|
||||
static get middleware(): string {
|
||||
return Input.getInput('middleware') ?? '';
|
||||
}
|
||||
|
||||
static get middlewareFiles(): string {
|
||||
return Input.getInput('middlewareFiles') ?? '';
|
||||
}
|
||||
|
||||
public static ToEnvVarFormat(input: string) {
|
||||
if (input.toUpperCase() === input) {
|
||||
return input;
|
||||
|
||||
@@ -166,6 +166,14 @@ class OrchestratorOptions {
|
||||
return OrchestratorOptions.getInput('commandHookFiles')?.split(`,`) || [];
|
||||
}
|
||||
|
||||
static get middleware(): string {
|
||||
return OrchestratorOptions.getInput('middleware') || '';
|
||||
}
|
||||
|
||||
static get middlewareFiles(): string[] {
|
||||
return OrchestratorOptions.getInput('middlewareFiles')?.split(',') || [];
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// Custom commands from yaml parameters
|
||||
// ### ### ###
|
||||
|
||||
@@ -129,6 +129,17 @@ 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);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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';
|
||||
532
src/model/orchestrator/providers/cli/cli-provider.test.ts
Normal file
532
src/model/orchestrator/providers/cli/cli-provider.test.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
406
src/model/orchestrator/providers/cli/cli-provider.ts
Normal file
406
src/model/orchestrator/providers/cli/cli-provider.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
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;
|
||||
1
src/model/orchestrator/providers/cli/index.ts
Normal file
1
src/model/orchestrator/providers/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './cli-provider';
|
||||
@@ -58,6 +58,7 @@ export default async function loadProvider(
|
||||
const providerModuleMap: Record<string, string> = {
|
||||
aws: './aws',
|
||||
k8s: './k8s',
|
||||
cli: './cli',
|
||||
test: './test',
|
||||
'local-docker': './docker',
|
||||
'local-system': './local',
|
||||
@@ -136,7 +137,7 @@ export class ProviderLoader {
|
||||
* @returns string[] - Array of available provider names
|
||||
*/
|
||||
static getAvailableProviders(): string[] {
|
||||
return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local'];
|
||||
return ['aws', 'k8s', 'cli', 'test', 'local-docker', 'local-system', 'local'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -237,6 +237,23 @@ 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),
|
||||
@@ -251,6 +268,19 @@ 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(
|
||||
|
||||
458
src/model/orchestrator/services/cache/child-workspace-service.test.ts
vendored
Normal file
458
src/model/orchestrator/services/cache/child-workspace-service.test.ts
vendored
Normal file
@@ -0,0 +1,458 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
373
src/model/orchestrator/services/cache/child-workspace-service.ts
vendored
Normal file
373
src/model/orchestrator/services/cache/child-workspace-service.ts
vendored
Normal file
@@ -0,0 +1,373 @@
|
||||
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]}`;
|
||||
}
|
||||
}
|
||||
272
src/model/orchestrator/services/cache/local-cache-service.test.ts
vendored
Normal file
272
src/model/orchestrator/services/cache/local-cache-service.test.ts
vendored
Normal file
@@ -0,0 +1,272 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
273
src/model/orchestrator/services/cache/local-cache-service.ts
vendored
Normal file
273
src/model/orchestrator/services/cache/local-cache-service.ts
vendored
Normal file
@@ -0,0 +1,273 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { OrchestratorSystem } from '../core/orchestrator-system';
|
||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||
|
||||
export class LocalCacheService {
|
||||
/**
|
||||
* Resolve the cache root directory based on build parameters and environment.
|
||||
* Priority: localCacheRoot > RUNNER_TEMP/game-ci-cache > .game-ci/cache
|
||||
*/
|
||||
static resolveCacheRoot(buildParameters: { localCacheRoot: string }): string {
|
||||
if (buildParameters.localCacheRoot) {
|
||||
return buildParameters.localCacheRoot;
|
||||
}
|
||||
|
||||
if (process.env.RUNNER_TEMP) {
|
||||
return path.join(process.env.RUNNER_TEMP, 'game-ci-cache');
|
||||
}
|
||||
|
||||
return path.join(process.cwd(), '.game-ci', 'cache');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sanitized cache key from build parameters.
|
||||
* Non-alphanumeric characters (except hyphens) are replaced with underscores.
|
||||
*/
|
||||
static generateCacheKey(targetPlatform: string, unityVersion: string, branch: string): string {
|
||||
const raw = `${targetPlatform}-${unityVersion}-${branch}`;
|
||||
|
||||
return raw.replace(/[^a-zA-Z0-9-]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore Unity Library cache from the local filesystem.
|
||||
* Returns true if cache was restored, false on cache miss.
|
||||
*/
|
||||
static async restoreLibraryCache(projectPath: string, cacheRoot: string, cacheKey: string): Promise<boolean> {
|
||||
const cachePath = path.join(cacheRoot, cacheKey, 'Library');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(cachePath)) {
|
||||
OrchestratorLogger.log(`[LocalCache] Library cache miss: ${cachePath}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(cachePath).filter((f) => f.endsWith('.tar'));
|
||||
if (files.length === 0) {
|
||||
OrchestratorLogger.log(`[LocalCache] Library cache miss (no tar files): ${cachePath}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the latest tar file by modification time
|
||||
let latestFile = files[0];
|
||||
let latestMtime = fs.statSync(path.join(cachePath, files[0])).mtimeMs;
|
||||
for (let i = 1; i < files.length; i++) {
|
||||
const mtime = fs.statSync(path.join(cachePath, files[i])).mtimeMs;
|
||||
if (mtime > latestMtime) {
|
||||
latestMtime = mtime;
|
||||
latestFile = files[i];
|
||||
}
|
||||
}
|
||||
|
||||
const tarPath = path.join(cachePath, latestFile);
|
||||
const libraryDest = path.join(projectPath, 'Library');
|
||||
|
||||
// Ensure destination exists
|
||||
fs.mkdirSync(libraryDest, { recursive: true });
|
||||
|
||||
OrchestratorLogger.log(`[LocalCache] Library cache hit: restoring from ${tarPath}`);
|
||||
await OrchestratorSystem.Run(`tar -xf "${tarPath}" -C "${projectPath}"`, true);
|
||||
OrchestratorLogger.log(`[LocalCache] Library cache restored successfully`);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] Library cache restore failed: ${error.message}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Unity Library folder to the local cache as a tar archive.
|
||||
* Keeps only the latest 2 cache entries.
|
||||
*/
|
||||
static async saveLibraryCache(projectPath: string, cacheRoot: string, cacheKey: string): Promise<void> {
|
||||
const libraryPath = path.join(projectPath, 'Library');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(libraryPath)) {
|
||||
OrchestratorLogger.log(`[LocalCache] Library folder does not exist, skipping save`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(libraryPath);
|
||||
if (entries.length === 0) {
|
||||
OrchestratorLogger.log(`[LocalCache] Library folder is empty, skipping save`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cachePath = path.join(cacheRoot, cacheKey, 'Library');
|
||||
fs.mkdirSync(cachePath, { recursive: true });
|
||||
|
||||
const timestamp = Date.now();
|
||||
const tarName = `lib-${timestamp}.tar`;
|
||||
const tarPath = path.join(cachePath, tarName);
|
||||
|
||||
OrchestratorLogger.log(`[LocalCache] Saving Library cache to ${tarPath}`);
|
||||
await OrchestratorSystem.Run(`tar -cf "${tarPath}" -C "${projectPath}" Library`, true);
|
||||
OrchestratorLogger.log(`[LocalCache] Library cache saved successfully`);
|
||||
|
||||
// Clean up old entries - keep latest 2
|
||||
await LocalCacheService.cleanupOldEntries(cachePath, 2);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] Library cache save failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore LFS cache from the local filesystem.
|
||||
* Returns true if cache was restored, false on cache miss.
|
||||
*/
|
||||
static async restoreLfsCache(repoPath: string, cacheRoot: string, cacheKey: string): Promise<boolean> {
|
||||
const cachePath = path.join(cacheRoot, cacheKey, 'lfs');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(cachePath)) {
|
||||
OrchestratorLogger.log(`[LocalCache] LFS cache miss: ${cachePath}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(cachePath).filter((f) => f.endsWith('.tar'));
|
||||
if (files.length === 0) {
|
||||
OrchestratorLogger.log(`[LocalCache] LFS cache miss (no tar files): ${cachePath}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the latest tar file by modification time
|
||||
let latestFile = files[0];
|
||||
let latestMtime = fs.statSync(path.join(cachePath, files[0])).mtimeMs;
|
||||
for (let i = 1; i < files.length; i++) {
|
||||
const mtime = fs.statSync(path.join(cachePath, files[i])).mtimeMs;
|
||||
if (mtime > latestMtime) {
|
||||
latestMtime = mtime;
|
||||
latestFile = files[i];
|
||||
}
|
||||
}
|
||||
|
||||
const tarPath = path.join(cachePath, latestFile);
|
||||
const lfsDest = path.join(repoPath, '.git', 'lfs');
|
||||
|
||||
// Ensure destination exists
|
||||
fs.mkdirSync(lfsDest, { recursive: true });
|
||||
|
||||
OrchestratorLogger.log(`[LocalCache] LFS cache hit: restoring from ${tarPath}`);
|
||||
await OrchestratorSystem.Run(`tar -xf "${tarPath}" -C "${path.join(repoPath, '.git')}"`, true);
|
||||
OrchestratorLogger.log(`[LocalCache] LFS cache restored successfully`);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] LFS cache restore failed: ${error.message}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save .git/lfs folder to the local cache as a tar archive.
|
||||
* Keeps only the latest 2 cache entries.
|
||||
*/
|
||||
static async saveLfsCache(repoPath: string, cacheRoot: string, cacheKey: string): Promise<void> {
|
||||
const lfsPath = path.join(repoPath, '.git', 'lfs');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(lfsPath)) {
|
||||
OrchestratorLogger.log(`[LocalCache] LFS folder does not exist, skipping save`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(lfsPath);
|
||||
if (entries.length === 0) {
|
||||
OrchestratorLogger.log(`[LocalCache] LFS folder is empty, skipping save`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cachePath = path.join(cacheRoot, cacheKey, 'lfs');
|
||||
fs.mkdirSync(cachePath, { recursive: true });
|
||||
|
||||
const timestamp = Date.now();
|
||||
const tarName = `lfs-${timestamp}.tar`;
|
||||
const tarPath = path.join(cachePath, tarName);
|
||||
|
||||
OrchestratorLogger.log(`[LocalCache] Saving LFS cache to ${tarPath}`);
|
||||
await OrchestratorSystem.Run(`tar -cf "${tarPath}" -C "${path.join(repoPath, '.git')}" lfs`, true);
|
||||
OrchestratorLogger.log(`[LocalCache] LFS cache saved successfully`);
|
||||
|
||||
// Clean up old entries - keep latest 2
|
||||
await LocalCacheService.cleanupOldEntries(cachePath, 2);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] LFS cache save failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove cache entries older than maxAgeDays from the cache root.
|
||||
*/
|
||||
static async garbageCollect(cacheRoot: string, maxAgeDays: number = 7): Promise<void> {
|
||||
try {
|
||||
if (!fs.existsSync(cacheRoot)) {
|
||||
OrchestratorLogger.log(`[LocalCache] Cache root does not exist, nothing to collect`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
const entries = fs.readdirSync(cacheRoot);
|
||||
let removedCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(cacheRoot, entry);
|
||||
try {
|
||||
const stat = fs.statSync(entryPath);
|
||||
if (stat.isDirectory() && now - stat.mtimeMs > maxAgeMs) {
|
||||
fs.rmSync(entryPath, { recursive: true, force: true });
|
||||
removedCount++;
|
||||
OrchestratorLogger.log(`[LocalCache] Garbage collected: ${entryPath}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] Failed to garbage collect ${entryPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
OrchestratorLogger.log(`[LocalCache] Garbage collection complete: ${removedCount} entries removed`);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] Garbage collection failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old tar files in a cache directory, keeping only the latest N.
|
||||
*/
|
||||
private static async cleanupOldEntries(cachePath: string, keepCount: number): Promise<void> {
|
||||
try {
|
||||
const files = fs
|
||||
.readdirSync(cachePath)
|
||||
.filter((f) => f.endsWith('.tar'))
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
mtime: fs.statSync(path.join(cachePath, f)).mtimeMs,
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
if (files.length > keepCount) {
|
||||
const toRemove = files.slice(keepCount);
|
||||
for (const file of toRemove) {
|
||||
const filePath = path.join(cachePath, file.name);
|
||||
fs.unlinkSync(filePath);
|
||||
OrchestratorLogger.log(`[LocalCache] Cleaned up old cache entry: ${filePath}`);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] Cleanup of old entries failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
405
src/model/orchestrator/services/hooks/git-hooks-service.test.ts
Normal file
405
src/model/orchestrator/services/hooks/git-hooks-service.test.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
235
src/model/orchestrator/services/hooks/git-hooks-service.ts
Normal file
235
src/model/orchestrator/services/hooks/git-hooks-service.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
422
src/model/orchestrator/services/hooks/middleware-service.test.ts
Normal file
422
src/model/orchestrator/services/hooks/middleware-service.test.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import { MiddlewareService } from './middleware-service';
|
||||
import { Middleware } from './middleware';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../orchestrator', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
buildParameters: {
|
||||
providerStrategy: 'aws',
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../options/orchestrator-options', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
providerStrategy: 'aws',
|
||||
middlewareFiles: ['code-signing', 'cache-optimizer'],
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../core/orchestrator-logger', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
log: jest.fn(),
|
||||
logWarning: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MiddlewareService', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('parseMiddleware', () => {
|
||||
it('should parse a single middleware definition', () => {
|
||||
const yaml = `
|
||||
name: test-middleware
|
||||
type: command
|
||||
priority: 50
|
||||
trigger:
|
||||
phase: [build]
|
||||
before:
|
||||
commands: echo "before"
|
||||
after:
|
||||
commands: echo "after"
|
||||
`;
|
||||
const result = MiddlewareService.parseMiddleware(yaml);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('test-middleware');
|
||||
expect(result[0].type).toBe('command');
|
||||
expect(result[0].priority).toBe(50);
|
||||
expect(result[0].trigger.phase).toEqual(['build']);
|
||||
expect(result[0].before?.commands).toBe('echo "before"');
|
||||
expect(result[0].after?.commands).toBe('echo "after"');
|
||||
});
|
||||
|
||||
it('should parse an array of middleware definitions', () => {
|
||||
const yaml = `
|
||||
- name: first
|
||||
type: command
|
||||
trigger:
|
||||
phase: [setup]
|
||||
before:
|
||||
commands: echo "first"
|
||||
- name: second
|
||||
type: container
|
||||
image: node:20
|
||||
trigger:
|
||||
phase: [build]
|
||||
after:
|
||||
commands: echo "second"
|
||||
`;
|
||||
const result = MiddlewareService.parseMiddleware(yaml);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('first');
|
||||
expect(result[0].type).toBe('command');
|
||||
expect(result[1].name).toBe('second');
|
||||
expect(result[1].type).toBe('container');
|
||||
expect(result[1].image).toBe('node:20');
|
||||
});
|
||||
|
||||
it('should handle string shorthand for before/after', () => {
|
||||
const yaml = `
|
||||
name: shorthand
|
||||
type: command
|
||||
trigger:
|
||||
phase: [build]
|
||||
before: echo "shorthand before"
|
||||
after: echo "shorthand after"
|
||||
`;
|
||||
const result = MiddlewareService.parseMiddleware(yaml);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].before?.commands).toBe('echo "shorthand before"');
|
||||
expect(result[0].after?.commands).toBe('echo "shorthand after"');
|
||||
});
|
||||
|
||||
it('should parse secrets', () => {
|
||||
const yaml = `
|
||||
name: with-secrets
|
||||
type: container
|
||||
trigger:
|
||||
phase: [build]
|
||||
secrets:
|
||||
- name: MY_SECRET
|
||||
value: secret-value
|
||||
- name: ANOTHER_SECRET
|
||||
after:
|
||||
commands: echo "done"
|
||||
`;
|
||||
const result = MiddlewareService.parseMiddleware(yaml);
|
||||
expect(result[0].secrets).toHaveLength(2);
|
||||
expect(result[0].secrets[0].ParameterKey).toBe('MY_SECRET');
|
||||
expect(result[0].secrets[0].ParameterValue).toBe('secret-value');
|
||||
expect(result[0].secrets[1].ParameterKey).toBe('ANOTHER_SECRET');
|
||||
});
|
||||
|
||||
it('should default priority to 100', () => {
|
||||
const yaml = `
|
||||
name: no-priority
|
||||
type: command
|
||||
trigger:
|
||||
phase: [build]
|
||||
before: echo "test"
|
||||
`;
|
||||
const result = MiddlewareService.parseMiddleware(yaml);
|
||||
expect(result[0].priority).toBe(100);
|
||||
});
|
||||
|
||||
it('should default type to command', () => {
|
||||
const yaml = `
|
||||
name: no-type
|
||||
trigger:
|
||||
phase: [build]
|
||||
before: echo "test"
|
||||
`;
|
||||
const result = MiddlewareService.parseMiddleware(yaml);
|
||||
expect(result[0].type).toBe('command');
|
||||
});
|
||||
|
||||
it('should return empty array for empty input', () => {
|
||||
expect(MiddlewareService.parseMiddleware('')).toEqual([]);
|
||||
expect(MiddlewareService.parseMiddleware(' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should normalize scalar trigger values to arrays', () => {
|
||||
const yaml = `
|
||||
name: scalar-triggers
|
||||
type: command
|
||||
trigger:
|
||||
phase: build
|
||||
provider: aws
|
||||
platform: StandaloneLinux64
|
||||
before: echo "test"
|
||||
`;
|
||||
const result = MiddlewareService.parseMiddleware(yaml);
|
||||
expect(result[0].trigger.phase).toEqual(['build']);
|
||||
expect(result[0].trigger.provider).toEqual(['aws']);
|
||||
expect(result[0].trigger.platform).toEqual(['StandaloneLinux64']);
|
||||
});
|
||||
|
||||
it('should handle per-phase image override for container type', () => {
|
||||
const yaml = `
|
||||
name: multi-image
|
||||
type: container
|
||||
image: ubuntu
|
||||
trigger:
|
||||
phase: [build]
|
||||
before:
|
||||
image: node:20
|
||||
commands: npm install
|
||||
after:
|
||||
image: python:3
|
||||
commands: python verify.py
|
||||
`;
|
||||
const result = MiddlewareService.parseMiddleware(yaml);
|
||||
expect(result[0].image).toBe('ubuntu');
|
||||
expect(result[0].before?.image).toBe('node:20');
|
||||
expect(result[0].after?.image).toBe('python:3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateExpression', () => {
|
||||
it('should evaluate equality expressions', () => {
|
||||
process.env.TEST_VAR = 'hello';
|
||||
expect(MiddlewareService.evaluateExpression("env.TEST_VAR == 'hello'")).toBe(true);
|
||||
expect(MiddlewareService.evaluateExpression("env.TEST_VAR == 'world'")).toBe(false);
|
||||
});
|
||||
|
||||
it('should evaluate inequality expressions', () => {
|
||||
process.env.TEST_VAR = 'hello';
|
||||
expect(MiddlewareService.evaluateExpression("env.TEST_VAR != 'world'")).toBe(true);
|
||||
expect(MiddlewareService.evaluateExpression("env.TEST_VAR != 'hello'")).toBe(false);
|
||||
});
|
||||
|
||||
it('should evaluate truthy expressions', () => {
|
||||
process.env.DEFINED_VAR = 'yes';
|
||||
expect(MiddlewareService.evaluateExpression('env.DEFINED_VAR')).toBe(true);
|
||||
|
||||
process.env.FALSE_VAR = 'false';
|
||||
expect(MiddlewareService.evaluateExpression('env.FALSE_VAR')).toBe(false);
|
||||
|
||||
process.env.EMPTY_VAR = '';
|
||||
expect(MiddlewareService.evaluateExpression('env.EMPTY_VAR')).toBe(false);
|
||||
|
||||
delete process.env.MISSING_VAR;
|
||||
expect(MiddlewareService.evaluateExpression('env.MISSING_VAR')).toBe(false);
|
||||
});
|
||||
|
||||
it('should evaluate falsy expressions', () => {
|
||||
delete process.env.MISSING_VAR;
|
||||
expect(MiddlewareService.evaluateExpression('!env.MISSING_VAR')).toBe(true);
|
||||
|
||||
process.env.DEFINED_VAR = 'yes';
|
||||
expect(MiddlewareService.evaluateExpression('!env.DEFINED_VAR')).toBe(false);
|
||||
});
|
||||
|
||||
it('should default to true for unknown expressions', () => {
|
||||
expect(MiddlewareService.evaluateExpression('some unknown expression')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle double-quoted values', () => {
|
||||
process.env.TEST_VAR = 'hello';
|
||||
expect(MiddlewareService.evaluateExpression('env.TEST_VAR == "hello"')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateTrigger', () => {
|
||||
it('should match when phase matches', () => {
|
||||
const trigger = { phase: ['build'] };
|
||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(true);
|
||||
expect(MiddlewareService.evaluateTrigger(trigger, 'setup')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match multiple phases', () => {
|
||||
const trigger = { phase: ['setup', 'build'] };
|
||||
expect(MiddlewareService.evaluateTrigger(trigger, 'setup')).toBe(true);
|
||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(true);
|
||||
expect(MiddlewareService.evaluateTrigger(trigger, 'pre-build')).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter by provider', () => {
|
||||
const trigger = { phase: ['build'], provider: ['k8s'] };
|
||||
// Mock provider is 'aws', so this should not match
|
||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
|
||||
|
||||
const matchingTrigger = { phase: ['build'], provider: ['aws', 'k8s'] };
|
||||
expect(MiddlewareService.evaluateTrigger(matchingTrigger, 'build')).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by platform', () => {
|
||||
const trigger = { phase: ['build'], platform: ['StandaloneWindows64'] };
|
||||
// Mock platform is 'StandaloneLinux64', so this should not match
|
||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
|
||||
|
||||
const matchingTrigger = { phase: ['build'], platform: ['StandaloneLinux64'] };
|
||||
expect(MiddlewareService.evaluateTrigger(matchingTrigger, 'build')).toBe(true);
|
||||
});
|
||||
|
||||
it('should evaluate when expressions', () => {
|
||||
process.env.FEATURE_FLAG = 'true';
|
||||
const trigger = { phase: ['build'], when: "env.FEATURE_FLAG == 'true'" };
|
||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(true);
|
||||
|
||||
process.env.FEATURE_FLAG = 'false';
|
||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
|
||||
});
|
||||
|
||||
it('should require all conditions to pass (AND logic)', () => {
|
||||
process.env.FEATURE_FLAG = 'true';
|
||||
const trigger = {
|
||||
phase: ['build'],
|
||||
provider: ['k8s'], // won't match (mock provider is 'aws')
|
||||
when: "env.FEATURE_FLAG == 'true'", // would match
|
||||
};
|
||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCommandHooks', () => {
|
||||
const middleware: Middleware[] = [
|
||||
{
|
||||
name: 'low-priority',
|
||||
type: 'command',
|
||||
priority: 10,
|
||||
trigger: { phase: ['build'] },
|
||||
image: 'ubuntu',
|
||||
before: { commands: 'echo "low-before"' },
|
||||
after: { commands: 'echo "low-after"' },
|
||||
secrets: [],
|
||||
allowFailure: false,
|
||||
},
|
||||
{
|
||||
name: 'high-priority',
|
||||
type: 'command',
|
||||
priority: 90,
|
||||
trigger: { phase: ['build'] },
|
||||
image: 'ubuntu',
|
||||
before: { commands: 'echo "high-before"' },
|
||||
after: { commands: 'echo "high-after"' },
|
||||
secrets: [],
|
||||
allowFailure: false,
|
||||
},
|
||||
{
|
||||
name: 'container-type',
|
||||
type: 'container',
|
||||
priority: 50,
|
||||
trigger: { phase: ['build'] },
|
||||
image: 'node:20',
|
||||
before: { commands: 'echo "container"' },
|
||||
secrets: [],
|
||||
allowFailure: false,
|
||||
},
|
||||
];
|
||||
|
||||
it('should only return command type middleware', () => {
|
||||
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
|
||||
expect(hooks).toHaveLength(2);
|
||||
expect(hooks.every((h) => h.name.startsWith('middleware:'))).toBe(true);
|
||||
expect(hooks.find((h) => h.name.includes('container-type'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should order before hooks by ascending priority', () => {
|
||||
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
|
||||
expect(hooks[0].name).toBe('middleware:low-priority:before');
|
||||
expect(hooks[1].name).toBe('middleware:high-priority:before');
|
||||
});
|
||||
|
||||
it('should order after hooks by descending priority (wrapping)', () => {
|
||||
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'build', 'after');
|
||||
expect(hooks[0].name).toBe('middleware:high-priority:after');
|
||||
expect(hooks[1].name).toBe('middleware:low-priority:after');
|
||||
});
|
||||
|
||||
it('should filter by phase', () => {
|
||||
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'setup', 'before');
|
||||
expect(hooks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveContainerHooks', () => {
|
||||
const middleware: Middleware[] = [
|
||||
{
|
||||
name: 'container-a',
|
||||
type: 'container',
|
||||
priority: 20,
|
||||
trigger: { phase: ['pre-build'] },
|
||||
image: 'ubuntu',
|
||||
before: { commands: 'echo "a-before"' },
|
||||
after: { commands: 'echo "a-after"' },
|
||||
secrets: [],
|
||||
allowFailure: false,
|
||||
},
|
||||
{
|
||||
name: 'container-b',
|
||||
type: 'container',
|
||||
priority: 80,
|
||||
trigger: { phase: ['pre-build'] },
|
||||
image: 'node:20',
|
||||
before: { commands: 'echo "b-before"', image: 'python:3' },
|
||||
secrets: [],
|
||||
allowFailure: true,
|
||||
},
|
||||
];
|
||||
|
||||
it('should resolve container hooks with correct images', () => {
|
||||
const hooks = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
|
||||
expect(hooks).toHaveLength(2);
|
||||
expect(hooks[0].image).toBe('ubuntu'); // default image from middleware
|
||||
expect(hooks[1].image).toBe('python:3'); // per-phase override
|
||||
});
|
||||
|
||||
it('should propagate allowFailure', () => {
|
||||
const hooks = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
|
||||
expect(hooks[0].allowFailure).toBe(false);
|
||||
expect(hooks[1].allowFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should only include phases that have definitions', () => {
|
||||
// container-b has no after phase
|
||||
const hooks = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'after');
|
||||
expect(hooks).toHaveLength(1);
|
||||
expect(hooks[0].name).toBe('middleware:container-a:after');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMiddleware', () => {
|
||||
it('should sort by priority ascending', () => {
|
||||
const yaml = `
|
||||
- name: high
|
||||
type: command
|
||||
priority: 200
|
||||
trigger:
|
||||
phase: [build]
|
||||
before: echo "high"
|
||||
- name: low
|
||||
type: command
|
||||
priority: 5
|
||||
trigger:
|
||||
phase: [build]
|
||||
before: echo "low"
|
||||
- name: medium
|
||||
type: command
|
||||
priority: 50
|
||||
trigger:
|
||||
phase: [build]
|
||||
before: echo "medium"
|
||||
`;
|
||||
const result = MiddlewareService.getMiddleware(yaml);
|
||||
expect(result[0].name).toBe('low');
|
||||
expect(result[1].name).toBe('medium');
|
||||
expect(result[2].name).toBe('high');
|
||||
});
|
||||
});
|
||||
});
|
||||
329
src/model/orchestrator/services/hooks/middleware-service.ts
Normal file
329
src/model/orchestrator/services/hooks/middleware-service.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import YAML from 'yaml';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Middleware, MiddlewareTrigger } from './middleware';
|
||||
import { ContainerHook } from './container-hook';
|
||||
import { CommandHook } from './command-hook';
|
||||
import OrchestratorOptions from '../../options/orchestrator-options';
|
||||
import Orchestrator from '../../orchestrator';
|
||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||
import Input from '../../../input';
|
||||
|
||||
/**
|
||||
* Service for loading, evaluating, and resolving middleware into hooks.
|
||||
*
|
||||
* Middleware is a higher-level composable abstraction over the existing
|
||||
* command hook and container hook systems. Each middleware:
|
||||
* - Wraps around pipeline phases with before/after semantics
|
||||
* - Has trigger conditions (phase, provider, platform, expression)
|
||||
* - Resolves to either CommandHooks or ContainerHooks at execution time
|
||||
* - Executes in priority order (before: ascending, after: descending)
|
||||
*/
|
||||
export class MiddlewareService {
|
||||
/**
|
||||
* Load all active middleware from inline YAML + file-based definitions.
|
||||
* Returns them sorted by priority (ascending).
|
||||
*/
|
||||
static getMiddleware(inlineYaml: string): Middleware[] {
|
||||
const middleware: Middleware[] = [];
|
||||
|
||||
// Parse inline YAML definitions
|
||||
if (inlineYaml && inlineYaml !== '') {
|
||||
middleware.push(...MiddlewareService.parseMiddleware(inlineYaml));
|
||||
}
|
||||
|
||||
// Load file-based definitions from game-ci/middleware/
|
||||
middleware.push(...MiddlewareService.getMiddlewareFromFiles());
|
||||
|
||||
// Sort by priority (lower = earlier)
|
||||
middleware.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
|
||||
|
||||
OrchestratorLogger.log(`Middleware: loaded ${middleware.length} definition(s)`);
|
||||
|
||||
return middleware;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve middleware to CommandHooks for a given phase and timing.
|
||||
* Filters by trigger conditions and converts to hooks.
|
||||
*
|
||||
* Before hooks: ascending priority (lowest priority runs first, closest to core phase).
|
||||
* After hooks: descending priority (highest priority runs first, closest to core phase).
|
||||
* This produces the wrapping pattern: outermost middleware's before runs first and after runs last.
|
||||
*/
|
||||
static resolveCommandHooks(middleware: Middleware[], phase: string, timing: 'before' | 'after'): CommandHook[] {
|
||||
const applicable = middleware
|
||||
.filter((m) => m.type === 'command')
|
||||
.filter((m) => MiddlewareService.evaluateTrigger(m.trigger, phase));
|
||||
|
||||
// before: ascending priority; after: descending (wrapping order)
|
||||
if (timing === 'after') {
|
||||
applicable.reverse();
|
||||
}
|
||||
|
||||
const hooks = applicable
|
||||
.filter((m) => (timing === 'before' ? m.before : m.after))
|
||||
.map((m) => {
|
||||
const mPhase = timing === 'before' ? m.before! : m.after!;
|
||||
const hook = new CommandHook();
|
||||
hook.name = `middleware:${m.name}:${timing}`;
|
||||
hook.commands = typeof mPhase.commands === 'string' ? [mPhase.commands] : [mPhase.commands];
|
||||
hook.hook = [timing];
|
||||
hook.step = [phase];
|
||||
hook.secrets = m.secrets || [];
|
||||
|
||||
return hook;
|
||||
});
|
||||
|
||||
if (hooks.length > 0) {
|
||||
OrchestratorLogger.log(
|
||||
`Middleware: resolved ${hooks.length} command hook(s) for ${phase}:${timing} — ${hooks
|
||||
.map((h) => h.name)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve middleware to ContainerHooks for a given phase and timing.
|
||||
* Same ordering logic as resolveCommandHooks.
|
||||
*/
|
||||
static resolveContainerHooks(middleware: Middleware[], phase: string, timing: 'before' | 'after'): ContainerHook[] {
|
||||
const applicable = middleware
|
||||
.filter((m) => m.type === 'container')
|
||||
.filter((m) => MiddlewareService.evaluateTrigger(m.trigger, phase));
|
||||
|
||||
// before: ascending priority; after: descending (wrapping order)
|
||||
if (timing === 'after') {
|
||||
applicable.reverse();
|
||||
}
|
||||
|
||||
const hooks = applicable
|
||||
.filter((m) => (timing === 'before' ? m.before : m.after))
|
||||
.map((m) => {
|
||||
const mPhase = timing === 'before' ? m.before! : m.after!;
|
||||
const hook = new ContainerHook();
|
||||
hook.name = `middleware:${m.name}:${timing}`;
|
||||
hook.commands = typeof mPhase.commands === 'string' ? mPhase.commands : mPhase.commands;
|
||||
hook.image = mPhase.image || m.image || 'ubuntu';
|
||||
hook.hook = timing === 'before' ? 'before' : 'after';
|
||||
hook.secrets = m.secrets || [];
|
||||
hook.allowFailure = m.allowFailure ?? false;
|
||||
|
||||
return hook;
|
||||
});
|
||||
|
||||
if (hooks.length > 0) {
|
||||
OrchestratorLogger.log(
|
||||
`Middleware: resolved ${hooks.length} container hook(s) for ${phase}:${timing} — ${hooks
|
||||
.map((h) => h.name)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate whether a middleware's trigger conditions are met.
|
||||
* All specified conditions must pass (AND logic).
|
||||
*/
|
||||
static evaluateTrigger(trigger: MiddlewareTrigger, currentPhase: string): boolean {
|
||||
// Phase must match
|
||||
if (!trigger.phase || !trigger.phase.includes(currentPhase)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Provider filter (if specified)
|
||||
if (trigger.provider && trigger.provider.length > 0) {
|
||||
const currentProvider = Orchestrator.buildParameters?.providerStrategy || OrchestratorOptions.providerStrategy;
|
||||
if (!trigger.provider.includes(currentProvider)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Platform filter (if specified)
|
||||
if (trigger.platform && trigger.platform.length > 0) {
|
||||
const currentPlatform = Orchestrator.buildParameters?.targetPlatform || '';
|
||||
if (!trigger.platform.includes(currentPlatform)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Expression-based condition
|
||||
if (trigger.when) {
|
||||
if (!MiddlewareService.evaluateExpression(trigger.when)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a simple expression string against environment variables.
|
||||
*
|
||||
* Supported formats:
|
||||
* - env.VAR_NAME == 'value' — equality check
|
||||
* - env.VAR_NAME != 'value' — inequality check
|
||||
* - env.VAR_NAME — truthy check (defined, non-empty, not 'false')
|
||||
* - !env.VAR_NAME — falsy check
|
||||
*/
|
||||
static evaluateExpression(expression: string): boolean {
|
||||
const trimmed = expression.trim();
|
||||
|
||||
// Match: env.VAR == 'value' or env.VAR != 'value'
|
||||
const comparisonMatch = trimmed.match(/^env\.(\w+)\s*(==|!=)\s*['"](.*)['"]$/);
|
||||
if (comparisonMatch) {
|
||||
const [, varName, operator, value] = comparisonMatch;
|
||||
const envValue = process.env[varName] || '';
|
||||
|
||||
return operator === '==' ? envValue === value : envValue !== value;
|
||||
}
|
||||
|
||||
// Match: !env.VAR (falsy check)
|
||||
const falsyMatch = trimmed.match(/^!env\.(\w+)$/);
|
||||
if (falsyMatch) {
|
||||
const [, varName] = falsyMatch;
|
||||
const envValue = process.env[varName];
|
||||
|
||||
return envValue === undefined || envValue === '' || envValue === 'false';
|
||||
}
|
||||
|
||||
// Match: env.VAR (truthy check)
|
||||
const truthyMatch = trimmed.match(/^env\.(\w+)$/);
|
||||
if (truthyMatch) {
|
||||
const [, varName] = truthyMatch;
|
||||
const envValue = process.env[varName];
|
||||
|
||||
return envValue !== undefined && envValue !== '' && envValue !== 'false';
|
||||
}
|
||||
|
||||
// Unknown expression format — log warning, default to true
|
||||
OrchestratorLogger.logWarning(`Middleware: unknown expression format "${expression}", defaulting to true`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse middleware definitions from a YAML string.
|
||||
* Accepts both single-object and array format.
|
||||
*/
|
||||
static parseMiddleware(yamlString: string): Middleware[] {
|
||||
if (!yamlString || yamlString.trim() === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const isArray = yamlString.replace(/\s/g, '')[0] === '-';
|
||||
const parsed = isArray ? YAML.parse(yamlString) : [YAML.parse(yamlString)];
|
||||
|
||||
if (!parsed || !Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.map((m: any) => MiddlewareService.hydrateMiddleware(m));
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`Middleware: failed to parse YAML — ${error.message}`);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate a raw parsed YAML object into a Middleware instance.
|
||||
*/
|
||||
private static hydrateMiddleware(m: any): Middleware {
|
||||
const middleware = new Middleware();
|
||||
middleware.name = m.name || 'unnamed';
|
||||
middleware.description = m.description;
|
||||
middleware.type = m.type || 'command';
|
||||
middleware.priority = m.priority ?? 100;
|
||||
middleware.image = m.image || 'ubuntu';
|
||||
middleware.allowFailure = m.allowFailure ?? false;
|
||||
middleware.outputs = m.outputs;
|
||||
|
||||
// Parse trigger — normalize scalar values to arrays
|
||||
middleware.trigger = {
|
||||
phase: MiddlewareService.toStringArray(m.trigger?.phase),
|
||||
provider: m.trigger?.provider ? MiddlewareService.toStringArray(m.trigger.provider) : undefined,
|
||||
platform: m.trigger?.platform ? MiddlewareService.toStringArray(m.trigger.platform) : undefined,
|
||||
when: m.trigger?.when,
|
||||
};
|
||||
|
||||
// Parse before/after phases — accept string shorthand or object format
|
||||
if (m.before) {
|
||||
middleware.before = {
|
||||
commands: typeof m.before === 'string' ? m.before : m.before.commands || '',
|
||||
image: typeof m.before === 'string' ? undefined : m.before.image,
|
||||
};
|
||||
}
|
||||
if (m.after) {
|
||||
middleware.after = {
|
||||
commands: typeof m.after === 'string' ? m.after : m.after.commands || '',
|
||||
image: typeof m.after === 'string' ? undefined : m.after.image,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse secrets
|
||||
if (m.secrets && Array.isArray(m.secrets)) {
|
||||
middleware.secrets = m.secrets.map((s: any) => ({
|
||||
ParameterKey: s.name,
|
||||
EnvironmentVariable: Input.ToEnvVarFormat(s.name),
|
||||
ParameterValue: s.value ?? process.env[s.name] ?? process.env[Input.ToEnvVarFormat(s.name)] ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
return middleware;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load middleware definitions from game-ci/middleware/ directory files.
|
||||
* Only files whose base name appears in the middlewareFiles allowlist are loaded.
|
||||
*/
|
||||
static getMiddlewareFromFiles(): Middleware[] {
|
||||
const results: Middleware[] = [];
|
||||
const allowedFiles = OrchestratorOptions.middlewareFiles;
|
||||
if (!allowedFiles || allowedFiles.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
try {
|
||||
const middlewarePath = path.join(process.cwd(), 'game-ci', 'middleware');
|
||||
if (!fs.existsSync(middlewarePath)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(middlewarePath);
|
||||
for (const file of files) {
|
||||
const baseName = file.replace(/\.ya?ml$/, '');
|
||||
if (!allowedFiles.includes(baseName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = fs.readFileSync(path.join(middlewarePath, file), 'utf8');
|
||||
results.push(...MiddlewareService.parseMiddleware(contents));
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`Middleware: failed to parse file ${file} — ${error.message}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read — not an error
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a value to a string array. Accepts string, string[], or undefined.
|
||||
*/
|
||||
private static toStringArray(value: any): string[] {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value;
|
||||
|
||||
return [value];
|
||||
}
|
||||
}
|
||||
85
src/model/orchestrator/services/hooks/middleware.ts
Normal file
85
src/model/orchestrator/services/hooks/middleware.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
||||
|
||||
/**
|
||||
* Trigger conditions that determine when a middleware activates.
|
||||
* All specified conditions must be true (AND logic).
|
||||
*/
|
||||
export interface MiddlewareTrigger {
|
||||
/** Pipeline phases this middleware applies to: 'setup', 'build', 'pre-build', 'post-build' */
|
||||
phase: string[];
|
||||
/** Restrict to specific providers. If omitted, applies to all providers. */
|
||||
provider?: string[];
|
||||
/** Restrict to specific build target platforms. If omitted, applies to all platforms. */
|
||||
platform?: string[];
|
||||
/** Expression-based condition. Supports: env.VAR == 'value', env.VAR != 'value', env.VAR (truthy) */
|
||||
when?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single phase (before or after) of a middleware definition.
|
||||
*/
|
||||
export interface MiddlewarePhase {
|
||||
/** Shell commands to execute */
|
||||
commands: string;
|
||||
/** Override image for this phase (container type only) */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware — a composable, trigger-aware pipeline unit built on hooks.
|
||||
*
|
||||
* Middleware wraps around pipeline phases with before/after semantics.
|
||||
* Each middleware resolves to either CommandHooks (inline in build container)
|
||||
* or ContainerHooks (separate Docker containers) at execution time.
|
||||
*
|
||||
* Execution order: before phases run in ascending priority order,
|
||||
* after phases run in descending priority order (wrapping pattern).
|
||||
*
|
||||
* Example YAML:
|
||||
* ```yaml
|
||||
* name: code-signing
|
||||
* description: Signs build artifacts after successful build
|
||||
* type: container
|
||||
* priority: 50
|
||||
* image: ubuntu:22.04
|
||||
* trigger:
|
||||
* phase: [build]
|
||||
* provider: [aws, k8s]
|
||||
* platform: [StandaloneWindows64]
|
||||
* when: "env.SIGN_BUILDS == 'true'"
|
||||
* before:
|
||||
* commands: |
|
||||
* echo "Preparing signing environment..."
|
||||
* after:
|
||||
* commands: |
|
||||
* echo "Signing build artifacts..."
|
||||
* secrets:
|
||||
* - name: SIGNING_KEY
|
||||
* - name: SIGNING_CERT
|
||||
* allowFailure: false
|
||||
* ```
|
||||
*/
|
||||
export class Middleware {
|
||||
/** Unique name identifying this middleware */
|
||||
public name!: string;
|
||||
/** Human-readable description */
|
||||
public description?: string;
|
||||
/** Hook type: 'command' (inline in build container) or 'container' (separate Docker container) */
|
||||
public type!: 'command' | 'container';
|
||||
/** Execution priority. Lower values run first for before, last for after (wrapping order). Default: 100 */
|
||||
public priority: number = 100;
|
||||
/** Conditions that determine when this middleware activates */
|
||||
public trigger!: MiddlewareTrigger;
|
||||
/** Default Docker image for container type middleware */
|
||||
public image: string = 'ubuntu';
|
||||
/** Commands to run before the target phase */
|
||||
public before?: MiddlewarePhase;
|
||||
/** Commands to run after the target phase */
|
||||
public after?: MiddlewarePhase;
|
||||
/** Secrets injected as environment variables */
|
||||
public secrets: OrchestratorSecret[] = [];
|
||||
/** If true, failures warn but don't stop the build. Default: false */
|
||||
public allowFailure: boolean = false;
|
||||
/** Environment variable names this middleware exports to downstream middleware */
|
||||
public outputs?: string[];
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
122
src/model/orchestrator/services/lfs/lfs-agent-service.test.ts
Normal file
122
src/model/orchestrator/services/lfs/lfs-agent-service.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
59
src/model/orchestrator/services/lfs/lfs-agent-service.ts
Normal file
59
src/model/orchestrator/services/lfs/lfs-agent-service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
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/"');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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[];
|
||||
@@ -6,6 +6,8 @@ import { CommandHookService } from '../services/hooks/command-hook-service';
|
||||
import path from 'node:path';
|
||||
import Orchestrator from '../orchestrator';
|
||||
import { ContainerHookService } from '../services/hooks/container-hook-service';
|
||||
import { MiddlewareService } from '../services/hooks/middleware-service';
|
||||
import { CustomWorkflow } from './custom-workflow';
|
||||
|
||||
export class BuildAutomationWorkflow implements WorkflowInterface {
|
||||
async run(orchestratorStepState: OrchestratorStepParameters) {
|
||||
@@ -13,13 +15,41 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
||||
}
|
||||
|
||||
private static async standardBuildAutomation(baseImage: string, orchestratorStepState: OrchestratorStepParameters) {
|
||||
// TODO accept post and pre build steps as yaml files in the repo
|
||||
OrchestratorLogger.log(`Orchestrator is running standard build automation`);
|
||||
|
||||
let output = '';
|
||||
|
||||
// Load middleware pipeline
|
||||
const middleware = MiddlewareService.getMiddleware(Orchestrator.buildParameters.middleware);
|
||||
|
||||
// --- Pre-build phase ---
|
||||
// Middleware container hooks: pre-build before
|
||||
const preBuildMiddlewareBefore = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
|
||||
if (preBuildMiddlewareBefore.length > 0) {
|
||||
output += await CustomWorkflow.runContainerJob(
|
||||
preBuildMiddlewareBefore,
|
||||
orchestratorStepState.environment,
|
||||
orchestratorStepState.secrets,
|
||||
);
|
||||
OrchestratorLogger.logWithTime('Middleware pre-build:before container step(s) time');
|
||||
}
|
||||
|
||||
// Standard pre-build container hooks
|
||||
output += await ContainerHookService.RunPreBuildSteps(orchestratorStepState);
|
||||
OrchestratorLogger.logWithTime('Configurable pre build step(s) time');
|
||||
|
||||
// Middleware container hooks: pre-build after
|
||||
const preBuildMiddlewareAfter = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'after');
|
||||
if (preBuildMiddlewareAfter.length > 0) {
|
||||
output += await CustomWorkflow.runContainerJob(
|
||||
preBuildMiddlewareAfter,
|
||||
orchestratorStepState.environment,
|
||||
orchestratorStepState.secrets,
|
||||
);
|
||||
OrchestratorLogger.logWithTime('Middleware pre-build:after container step(s) time');
|
||||
}
|
||||
|
||||
// --- Build phase ---
|
||||
OrchestratorLogger.log(baseImage);
|
||||
OrchestratorLogger.logLine(` `);
|
||||
OrchestratorLogger.logLine('Starting build automation job');
|
||||
@@ -35,9 +65,33 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
||||
);
|
||||
OrchestratorLogger.logWithTime('Build time');
|
||||
|
||||
// --- Post-build phase ---
|
||||
// Middleware container hooks: post-build before
|
||||
const postBuildMiddlewareBefore = MiddlewareService.resolveContainerHooks(middleware, 'post-build', 'before');
|
||||
if (postBuildMiddlewareBefore.length > 0) {
|
||||
output += await CustomWorkflow.runContainerJob(
|
||||
postBuildMiddlewareBefore,
|
||||
orchestratorStepState.environment,
|
||||
orchestratorStepState.secrets,
|
||||
);
|
||||
OrchestratorLogger.logWithTime('Middleware post-build:before container step(s) time');
|
||||
}
|
||||
|
||||
// Standard post-build container hooks
|
||||
output += await ContainerHookService.RunPostBuildSteps(orchestratorStepState);
|
||||
OrchestratorLogger.logWithTime('Configurable post build step(s) time');
|
||||
|
||||
// Middleware container hooks: post-build after
|
||||
const postBuildMiddlewareAfter = MiddlewareService.resolveContainerHooks(middleware, 'post-build', 'after');
|
||||
if (postBuildMiddlewareAfter.length > 0) {
|
||||
output += await CustomWorkflow.runContainerJob(
|
||||
postBuildMiddlewareAfter,
|
||||
orchestratorStepState.environment,
|
||||
orchestratorStepState.secrets,
|
||||
);
|
||||
OrchestratorLogger.logWithTime('Middleware post-build:after container step(s) time');
|
||||
}
|
||||
|
||||
OrchestratorLogger.log(`Orchestrator finished running standard build automation`);
|
||||
|
||||
return output;
|
||||
@@ -50,6 +104,13 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
||||
const buildHooks = CommandHookService.getHooks(Orchestrator.buildParameters.commandHooks).filter((x) =>
|
||||
x.step?.includes(`build`),
|
||||
);
|
||||
|
||||
// Resolve middleware command hooks for setup and build phases
|
||||
const middleware = MiddlewareService.getMiddleware(Orchestrator.buildParameters.middleware);
|
||||
const middlewareSetupBefore = MiddlewareService.resolveCommandHooks(middleware, 'setup', 'before');
|
||||
const middlewareSetupAfter = MiddlewareService.resolveCommandHooks(middleware, 'setup', 'after');
|
||||
const middlewareBuildBefore = MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
|
||||
const middlewareBuildAfter = MiddlewareService.resolveCommandHooks(middleware, 'build', 'after');
|
||||
const isContainerized =
|
||||
Orchestrator.buildParameters.providerStrategy === 'aws' ||
|
||||
Orchestrator.buildParameters.providerStrategy === 'k8s' ||
|
||||
@@ -71,6 +132,7 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
||||
? 'apt-get install -y curl tar tree npm git-lfs jq git > /dev/null || true\n npm --version || true\n npm i -g n > /dev/null || true\n npm i -g semver > /dev/null || true\n npm install --global yarn > /dev/null || true\n n 20.8.0 || true\n node --version || true'
|
||||
: '# skipping toolchain setup in local-docker or non-container provider'
|
||||
}
|
||||
${middlewareSetupBefore.map((x) => x.commands).join('\n ') || ' '}
|
||||
${setupHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
|
||||
${
|
||||
Orchestrator.buildParameters.providerStrategy === 'local-docker'
|
||||
@@ -82,9 +144,12 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
||||
export LOG_FILE=${isContainerized ? '/home/job-log.txt' : '$(pwd)/temp/job-log.txt'}
|
||||
${BuildAutomationWorkflow.setupCommands(builderPath, isContainerized)}
|
||||
${setupHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
|
||||
${middlewareSetupAfter.map((x) => x.commands).join('\n ') || ' '}
|
||||
${middlewareBuildBefore.map((x) => x.commands).join('\n ') || ' '}
|
||||
${buildHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
|
||||
${BuildAutomationWorkflow.BuildCommands(builderPath, isContainerized)}
|
||||
${buildHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}`;
|
||||
${buildHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
|
||||
${middlewareBuildAfter.map((x) => x.commands).join('\n ') || ' '}`;
|
||||
}
|
||||
|
||||
private static setupCommands(builderPath: string, isContainerized: boolean) {
|
||||
|
||||
420
yarn.lock
420
yarn.lock
@@ -1006,15 +1006,6 @@
|
||||
eslint-visitor-keys "^2.1.0"
|
||||
semver "^6.3.1"
|
||||
|
||||
"@babel/generator@7.18.2":
|
||||
version "7.18.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.2.tgz#33873d6f89b21efe2da63fe554460f3df1c5880d"
|
||||
integrity sha512-W1lG5vUwFvfMd8HVXqdfbuG7RuaSrTCCD8cl8fP8wOivdbtbIg2Db3IWUcgvfxKbbn6ZBGYRW/Zk1MIwK49mgw==
|
||||
dependencies:
|
||||
"@babel/types" "^7.18.2"
|
||||
"@jridgewell/gen-mapping" "^0.3.0"
|
||||
jsesc "^2.5.1"
|
||||
|
||||
"@babel/generator@^7.22.10", "@babel/generator@^7.7.2":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.22.10.tgz#c92254361f398e160645ac58831069707382b722"
|
||||
@@ -1108,21 +1099,11 @@
|
||||
dependencies:
|
||||
"@babel/types" "^7.22.5"
|
||||
|
||||
"@babel/helper-string-parser@^7.18.10", "@babel/helper-string-parser@^7.27.1":
|
||||
version "7.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
|
||||
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
|
||||
|
||||
"@babel/helper-string-parser@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f"
|
||||
integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==
|
||||
|
||||
"@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.28.5":
|
||||
version "7.28.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4"
|
||||
integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
|
||||
|
||||
"@babel/helper-validator-identifier@^7.22.20":
|
||||
version "7.22.20"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
|
||||
@@ -1165,11 +1146,6 @@
|
||||
chalk "^2.4.2"
|
||||
js-tokens "^4.0.0"
|
||||
|
||||
"@babel/parser@7.18.4":
|
||||
version "7.18.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.4.tgz#6774231779dd700e0af29f6ad8d479582d7ce5ef"
|
||||
integrity sha512-FDge0dFazETFcxGw/EXzOkN8uJp0PC7Qbm+Pe9T+av2zlBpOgunFHkQPPn+eRuClU73JF+98D531UgayY89tow==
|
||||
|
||||
"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.22.10", "@babel/parser@^7.22.5":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.10.tgz#e37634f9a12a1716136c44624ef54283cabd3f55"
|
||||
@@ -1312,15 +1288,6 @@
|
||||
debug "^4.1.0"
|
||||
globals "^11.1.0"
|
||||
|
||||
"@babel/types@7.19.0":
|
||||
version "7.19.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.0.tgz#75f21d73d73dc0351f3368d28db73465f4814600"
|
||||
integrity sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA==
|
||||
dependencies:
|
||||
"@babel/helper-string-parser" "^7.18.10"
|
||||
"@babel/helper-validator-identifier" "^7.18.6"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.5", "@babel/types@^7.3.3":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.10.tgz#4a9e76446048f2c66982d1a989dd12b8a2d2dc03"
|
||||
@@ -1330,14 +1297,6 @@
|
||||
"@babel/helper-validator-identifier" "^7.22.5"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@babel/types@^7.18.2":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7"
|
||||
integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==
|
||||
dependencies:
|
||||
"@babel/helper-string-parser" "^7.27.1"
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
|
||||
"@babel/types@^7.22.15", "@babel/types@^7.23.0":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb"
|
||||
@@ -2708,13 +2667,6 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@types/yargs@^17.0.35":
|
||||
version "17.0.35"
|
||||
resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.35.tgz#07013e46aa4d7d7d50a49e15604c1c5340d4eb24"
|
||||
integrity sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@types/yarnpkg__lockfile@^1.1.6":
|
||||
version "1.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/yarnpkg__lockfile/-/yarnpkg__lockfile-1.1.6.tgz#60a35ede6197d8cbedd5bb8393f3921e8d56d44b"
|
||||
@@ -3163,11 +3115,6 @@ asynckit@^0.4.0:
|
||||
resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz"
|
||||
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
|
||||
|
||||
at-least-node@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2"
|
||||
integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==
|
||||
|
||||
available-typed-arrays@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7"
|
||||
@@ -3282,7 +3229,7 @@ base-64@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/base-64/-/base-64-1.0.0.tgz#09d0f2084e32a3fd08c2475b973788eee6ae8f4a"
|
||||
integrity sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==
|
||||
|
||||
base64-js@^1.0.2, base64-js@^1.3.1:
|
||||
base64-js@^1.0.2:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
@@ -3330,15 +3277,6 @@ big-integer@^1.6.44:
|
||||
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
|
||||
integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==
|
||||
|
||||
bl@^4.0.3:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
|
||||
integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
|
||||
dependencies:
|
||||
buffer "^5.5.0"
|
||||
inherits "^2.0.4"
|
||||
readable-stream "^3.4.0"
|
||||
|
||||
bowser@^2.11.0:
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f"
|
||||
@@ -3409,14 +3347,6 @@ buffer@4.9.2:
|
||||
ieee754 "^1.1.4"
|
||||
isarray "^1.0.0"
|
||||
|
||||
buffer@^5.5.0:
|
||||
version "5.7.1"
|
||||
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
|
||||
integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
|
||||
dependencies:
|
||||
base64-js "^1.3.1"
|
||||
ieee754 "^1.1.13"
|
||||
|
||||
bundle-name@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/bundle-name/-/bundle-name-3.0.0.tgz#ba59bcc9ac785fb67ccdbf104a2bf60c099f0e1a"
|
||||
@@ -3510,7 +3440,7 @@ chalk@^2.4.2:
|
||||
escape-string-regexp "^1.0.5"
|
||||
supports-color "^5.3.0"
|
||||
|
||||
chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2:
|
||||
chalk@^4.0.0, chalk@^4.1.0:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz"
|
||||
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
|
||||
@@ -3533,11 +3463,6 @@ charenc@0.0.2:
|
||||
resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
|
||||
integrity sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==
|
||||
|
||||
chownr@^1.1.1:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
|
||||
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
|
||||
|
||||
chownr@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz"
|
||||
@@ -3579,15 +3504,6 @@ cliui@^7.0.2:
|
||||
strip-ansi "^6.0.0"
|
||||
wrap-ansi "^7.0.0"
|
||||
|
||||
cliui@^8.0.1:
|
||||
version "8.0.1"
|
||||
resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa"
|
||||
integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==
|
||||
dependencies:
|
||||
string-width "^4.2.0"
|
||||
strip-ansi "^6.0.1"
|
||||
wrap-ansi "^7.0.0"
|
||||
|
||||
clone-response@^1.0.2:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3"
|
||||
@@ -3698,11 +3614,6 @@ core-util-is@1.0.2:
|
||||
resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
|
||||
integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==
|
||||
|
||||
core-util-is@~1.0.0:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
|
||||
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
|
||||
|
||||
create-require@^1.1.0:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz"
|
||||
@@ -3821,11 +3732,6 @@ dedent@^0.7.0:
|
||||
resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c"
|
||||
integrity sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==
|
||||
|
||||
deep-extend@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
|
||||
integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
|
||||
|
||||
deep-is@^0.1.3:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz"
|
||||
@@ -3897,11 +3803,6 @@ dequal@^2.0.3:
|
||||
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
|
||||
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
|
||||
|
||||
detect-libc@^2.0.0:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
|
||||
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
|
||||
|
||||
detect-newline@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
|
||||
@@ -3998,13 +3899,6 @@ end-of-stream@^1.1.0:
|
||||
dependencies:
|
||||
once "^1.4.0"
|
||||
|
||||
end-of-stream@^1.4.1:
|
||||
version "1.4.5"
|
||||
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c"
|
||||
integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==
|
||||
dependencies:
|
||||
once "^1.4.0"
|
||||
|
||||
enquirer@^2.3.5:
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.4.1.tgz#93334b3fbd74fc7097b224ab4a8fb7e40bf4ae56"
|
||||
@@ -4504,11 +4398,6 @@ exit@^0.1.2:
|
||||
resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
|
||||
integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==
|
||||
|
||||
expand-template@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c"
|
||||
integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==
|
||||
|
||||
expect@^27.5.1:
|
||||
version "27.5.1"
|
||||
resolved "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz"
|
||||
@@ -4692,19 +4581,6 @@ form-data@~2.3.2:
|
||||
combined-stream "^1.0.6"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
from2@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af"
|
||||
integrity sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==
|
||||
dependencies:
|
||||
inherits "^2.0.1"
|
||||
readable-stream "^2.0.0"
|
||||
|
||||
fs-constants@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
|
||||
integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
|
||||
|
||||
fs-extra@^11.1.1:
|
||||
version "11.1.1"
|
||||
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.1.tgz#da69f7c39f3b002378b0954bb6ae7efdc0876e2d"
|
||||
@@ -4714,16 +4590,6 @@ fs-extra@^11.1.1:
|
||||
jsonfile "^6.0.1"
|
||||
universalify "^2.0.0"
|
||||
|
||||
fs-extra@^9.1.0:
|
||||
version "9.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d"
|
||||
integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==
|
||||
dependencies:
|
||||
at-least-node "^1.0.0"
|
||||
graceful-fs "^4.2.0"
|
||||
jsonfile "^6.0.1"
|
||||
universalify "^2.0.0"
|
||||
|
||||
fs-minipass@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz"
|
||||
@@ -4746,11 +4612,6 @@ function-bind@^1.1.1:
|
||||
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
|
||||
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
|
||||
|
||||
function-bind@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
|
||||
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
|
||||
|
||||
function.prototype.name@^1.1.5:
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621"
|
||||
@@ -4830,11 +4691,6 @@ getpass@^0.1.1:
|
||||
dependencies:
|
||||
assert-plus "^1.0.0"
|
||||
|
||||
github-from-package@0.0.0:
|
||||
version "0.0.0"
|
||||
resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce"
|
||||
integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==
|
||||
|
||||
glob-parent@^5.1.2:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
|
||||
@@ -5019,13 +4875,6 @@ has@^1.0.3:
|
||||
dependencies:
|
||||
function-bind "^1.1.1"
|
||||
|
||||
hasown@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
|
||||
integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
|
||||
dependencies:
|
||||
function-bind "^1.1.2"
|
||||
|
||||
hosted-git-info@^2.1.4:
|
||||
version "2.8.9"
|
||||
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
|
||||
@@ -5104,7 +4953,7 @@ ieee754@1.1.13:
|
||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
|
||||
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
|
||||
|
||||
ieee754@^1.1.13, ieee754@^1.1.4:
|
||||
ieee754@^1.1.4:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
||||
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
|
||||
@@ -5158,16 +5007,11 @@ inflight@^1.0.4:
|
||||
once "^1.3.0"
|
||||
wrappy "1"
|
||||
|
||||
inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3:
|
||||
inherits@2, inherits@^2.0.3:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
|
||||
ini@~1.3.0:
|
||||
version "1.3.8"
|
||||
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
|
||||
integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
|
||||
|
||||
internal-slot@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.5.tgz#f2a2ee21f668f8627a4667f309dc0f4fb6674986"
|
||||
@@ -5182,14 +5026,6 @@ interpret@^1.0.0:
|
||||
resolved "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz"
|
||||
integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
|
||||
|
||||
into-stream@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/into-stream/-/into-stream-6.0.0.tgz#4bfc1244c0128224e18b8870e85b2de8e66c6702"
|
||||
integrity sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==
|
||||
dependencies:
|
||||
from2 "^2.3.0"
|
||||
p-is-promise "^3.0.0"
|
||||
|
||||
is-arguments@^1.0.4:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b"
|
||||
@@ -5237,13 +5073,6 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7:
|
||||
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
|
||||
integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==
|
||||
|
||||
is-core-module@2.9.0:
|
||||
version "2.9.0"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69"
|
||||
integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==
|
||||
dependencies:
|
||||
has "^1.0.3"
|
||||
|
||||
is-core-module@^2.12.1, is-core-module@^2.13.0:
|
||||
version "2.13.0"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.0.tgz#bb52aa6e2cbd49a30c2ba68c42bf3435ba6072db"
|
||||
@@ -5251,13 +5080,6 @@ is-core-module@^2.12.1, is-core-module@^2.13.0:
|
||||
dependencies:
|
||||
has "^1.0.3"
|
||||
|
||||
is-core-module@^2.16.1:
|
||||
version "2.16.1"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4"
|
||||
integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==
|
||||
dependencies:
|
||||
hasown "^2.0.2"
|
||||
|
||||
is-date-object@^1.0.1:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f"
|
||||
@@ -5413,7 +5235,7 @@ is-wsl@^2.2.0:
|
||||
dependencies:
|
||||
is-docker "^2.0.0"
|
||||
|
||||
isarray@^1.0.0, isarray@~1.0.0:
|
||||
isarray@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
|
||||
integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==
|
||||
@@ -6375,7 +6197,7 @@ minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2:
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.6:
|
||||
minimist@^1.2.0, minimist@^1.2.6:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
|
||||
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||
@@ -6405,11 +6227,6 @@ minizlib@^2.1.1:
|
||||
minipass "^3.0.0"
|
||||
yallist "^4.0.0"
|
||||
|
||||
mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
|
||||
version "0.5.3"
|
||||
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
|
||||
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
|
||||
|
||||
mkdirp@^1.0.3:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz"
|
||||
@@ -6430,24 +6247,11 @@ multimap@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/multimap/-/multimap-1.1.0.tgz#5263febc085a1791c33b59bb3afc6a76a2a10ca8"
|
||||
integrity sha512-0ZIR9PasPxGXmRsEF8jsDzndzHDj7tIav+JUmvIFB/WHswliFnquxECT/De7GR4yg99ky/NlRKJT82G1y271bw==
|
||||
|
||||
multistream@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/multistream/-/multistream-4.1.0.tgz#7bf00dfd119556fbc153cff3de4c6d477909f5a8"
|
||||
integrity sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==
|
||||
dependencies:
|
||||
once "^1.4.0"
|
||||
readable-stream "^3.6.0"
|
||||
|
||||
nanoid@^3.3.1:
|
||||
version "3.3.6"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
|
||||
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
|
||||
|
||||
napi-build-utils@^1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806"
|
||||
integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==
|
||||
|
||||
natural-compare@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
@@ -6468,14 +6272,7 @@ no-case@^3.0.4:
|
||||
lower-case "^2.0.2"
|
||||
tslib "^2.0.3"
|
||||
|
||||
node-abi@^3.3.0:
|
||||
version "3.87.0"
|
||||
resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.87.0.tgz#423e28fea5c2f195fddd98acded9938c001ae6dd"
|
||||
integrity sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==
|
||||
dependencies:
|
||||
semver "^7.3.5"
|
||||
|
||||
node-fetch@2, node-fetch@^2.6.6, node-fetch@^2.7.0:
|
||||
node-fetch@2:
|
||||
version "2.7.0"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
|
||||
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
||||
@@ -6489,6 +6286,13 @@ node-fetch@^2.6.7:
|
||||
dependencies:
|
||||
whatwg-url "^5.0.0"
|
||||
|
||||
node-fetch@^2.7.0:
|
||||
version "2.7.0"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
|
||||
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
||||
dependencies:
|
||||
whatwg-url "^5.0.0"
|
||||
|
||||
node-int64@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
|
||||
@@ -6704,11 +6508,6 @@ p-cancelable@^2.0.0:
|
||||
resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz"
|
||||
integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==
|
||||
|
||||
p-is-promise@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-3.0.0.tgz#58e78c7dfe2e163cf2a04ff869e7c1dba64a5971"
|
||||
integrity sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==
|
||||
|
||||
p-limit@^2.2.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
|
||||
@@ -6854,63 +6653,11 @@ pkg-dir@^7.0.0:
|
||||
dependencies:
|
||||
find-up "^6.3.0"
|
||||
|
||||
pkg-fetch@3.4.2:
|
||||
version "3.4.2"
|
||||
resolved "https://registry.yarnpkg.com/pkg-fetch/-/pkg-fetch-3.4.2.tgz#6f68ebc54842b73f8c0808959a9df3739dcb28b7"
|
||||
integrity sha512-0+uijmzYcnhC0hStDjm/cl2VYdrmVVBpe7Q8k9YBojxmR5tG8mvR9/nooQq3QSXiQqORDVOTY3XqMEqJVIzkHA==
|
||||
dependencies:
|
||||
chalk "^4.1.2"
|
||||
fs-extra "^9.1.0"
|
||||
https-proxy-agent "^5.0.0"
|
||||
node-fetch "^2.6.6"
|
||||
progress "^2.0.3"
|
||||
semver "^7.3.5"
|
||||
tar-fs "^2.1.1"
|
||||
yargs "^16.2.0"
|
||||
|
||||
pkg@^5.8.1:
|
||||
version "5.8.1"
|
||||
resolved "https://registry.yarnpkg.com/pkg/-/pkg-5.8.1.tgz#862020f3c0575638ef7d1146f951a54d65ddc984"
|
||||
integrity sha512-CjBWtFStCfIiT4Bde9QpJy0KeH19jCfwZRJqHFDFXfhUklCx8JoFmMj3wgnEYIwGmZVNkhsStPHEOnrtrQhEXA==
|
||||
dependencies:
|
||||
"@babel/generator" "7.18.2"
|
||||
"@babel/parser" "7.18.4"
|
||||
"@babel/types" "7.19.0"
|
||||
chalk "^4.1.2"
|
||||
fs-extra "^9.1.0"
|
||||
globby "^11.1.0"
|
||||
into-stream "^6.0.0"
|
||||
is-core-module "2.9.0"
|
||||
minimist "^1.2.6"
|
||||
multistream "^4.1.0"
|
||||
pkg-fetch "3.4.2"
|
||||
prebuild-install "7.1.1"
|
||||
resolve "^1.22.0"
|
||||
stream-meter "^1.0.4"
|
||||
|
||||
pluralize@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1"
|
||||
integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==
|
||||
|
||||
prebuild-install@7.1.1:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.1.tgz#de97d5b34a70a0c81334fd24641f2a1702352e45"
|
||||
integrity sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==
|
||||
dependencies:
|
||||
detect-libc "^2.0.0"
|
||||
expand-template "^2.0.3"
|
||||
github-from-package "0.0.0"
|
||||
minimist "^1.2.3"
|
||||
mkdirp-classic "^0.5.3"
|
||||
napi-build-utils "^1.0.1"
|
||||
node-abi "^3.3.0"
|
||||
pump "^3.0.0"
|
||||
rc "^1.2.7"
|
||||
simple-get "^4.0.0"
|
||||
tar-fs "^2.0.0"
|
||||
tunnel-agent "^0.6.0"
|
||||
|
||||
prelude-ls@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
|
||||
@@ -6947,17 +6694,12 @@ pretty-format@^27.0.0, pretty-format@^27.5.1:
|
||||
ansi-styles "^5.0.0"
|
||||
react-is "^17.0.1"
|
||||
|
||||
process-nextick-args@~2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
|
||||
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
|
||||
|
||||
process@^0.11.10:
|
||||
version "0.11.10"
|
||||
resolved "https://registry.npmjs.org/process/-/process-0.11.10.tgz"
|
||||
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
|
||||
|
||||
progress@^2.0.0, progress@^2.0.3:
|
||||
progress@^2.0.0:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
|
||||
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
|
||||
@@ -7030,16 +6772,6 @@ quick-lru@^5.1.1:
|
||||
resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz"
|
||||
integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
|
||||
|
||||
rc@^1.2.7:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
|
||||
integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
|
||||
dependencies:
|
||||
deep-extend "^0.6.0"
|
||||
ini "~1.3.0"
|
||||
minimist "^1.2.0"
|
||||
strip-json-comments "~2.0.1"
|
||||
|
||||
react-is@^17.0.1:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
|
||||
@@ -7064,28 +6796,6 @@ read-pkg@^5.2.0:
|
||||
parse-json "^5.0.0"
|
||||
type-fest "^0.6.0"
|
||||
|
||||
readable-stream@^2.0.0, readable-stream@^2.1.4:
|
||||
version "2.3.8"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b"
|
||||
integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==
|
||||
dependencies:
|
||||
core-util-is "~1.0.0"
|
||||
inherits "~2.0.3"
|
||||
isarray "~1.0.0"
|
||||
process-nextick-args "~2.0.0"
|
||||
safe-buffer "~5.1.1"
|
||||
string_decoder "~1.1.1"
|
||||
util-deprecate "~1.0.1"
|
||||
|
||||
readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0:
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
|
||||
integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
|
||||
dependencies:
|
||||
inherits "^2.0.3"
|
||||
string_decoder "^1.1.1"
|
||||
util-deprecate "^1.0.1"
|
||||
|
||||
rechoir@^0.6.2:
|
||||
version "0.6.2"
|
||||
resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384"
|
||||
@@ -7204,15 +6914,6 @@ resolve@^1.1.6, resolve@^1.10.0, resolve@^1.20.0, resolve@^1.22.3, resolve@^1.22
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
resolve@^1.22.0:
|
||||
version "1.22.11"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262"
|
||||
integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==
|
||||
dependencies:
|
||||
is-core-module "^2.16.1"
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
responselike@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7"
|
||||
@@ -7268,16 +6969,11 @@ safe-array-concat@^1.0.0:
|
||||
has-symbols "^1.0.3"
|
||||
isarray "^2.0.5"
|
||||
|
||||
safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
|
||||
safe-buffer@^5.0.1, safe-buffer@^5.1.2:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
|
||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
|
||||
safe-buffer@~5.1.0, safe-buffer@~5.1.1:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
|
||||
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
|
||||
|
||||
safe-regex-test@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295"
|
||||
@@ -7385,20 +7081,6 @@ signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7:
|
||||
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz"
|
||||
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
|
||||
|
||||
simple-concat@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f"
|
||||
integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==
|
||||
|
||||
simple-get@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543"
|
||||
integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==
|
||||
dependencies:
|
||||
decompress-response "^6.0.0"
|
||||
once "^1.3.1"
|
||||
simple-concat "^1.0.0"
|
||||
|
||||
sisteransi@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
|
||||
@@ -7504,13 +7186,6 @@ stream-buffers@^3.0.2:
|
||||
resolved "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.2.tgz"
|
||||
integrity sha512-DQi1h8VEBA/lURbSwFtEHnSTb9s2/pwLEaFuNhXwy1Dx3Sa0lOuYT2yNUr4/j2fs8oCAMANtrZ5OrPZtyVs3MQ==
|
||||
|
||||
stream-meter@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/stream-meter/-/stream-meter-1.0.4.tgz#52af95aa5ea760a2491716704dbff90f73afdd1d"
|
||||
integrity sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==
|
||||
dependencies:
|
||||
readable-stream "^2.1.4"
|
||||
|
||||
string-length@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"
|
||||
@@ -7555,20 +7230,6 @@ string.prototype.trimstart@^1.0.6:
|
||||
define-properties "^1.1.4"
|
||||
es-abstract "^1.20.4"
|
||||
|
||||
string_decoder@^1.1.1:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
|
||||
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
|
||||
dependencies:
|
||||
safe-buffer "~5.2.0"
|
||||
|
||||
string_decoder@~1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
|
||||
integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
|
||||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
@@ -7601,11 +7262,6 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
|
||||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
|
||||
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
|
||||
|
||||
strip-json-comments@~2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
|
||||
integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==
|
||||
|
||||
strnum@^1.0.5:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.1.2.tgz#57bca4fbaa6f271081715dbc9ed7cee5493e28e4"
|
||||
@@ -7698,27 +7354,6 @@ table@^6.0.9:
|
||||
string-width "^4.2.3"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
tar-fs@^2.0.0, tar-fs@^2.1.1:
|
||||
version "2.1.4"
|
||||
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930"
|
||||
integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==
|
||||
dependencies:
|
||||
chownr "^1.1.1"
|
||||
mkdirp-classic "^0.5.2"
|
||||
pump "^3.0.0"
|
||||
tar-stream "^2.1.4"
|
||||
|
||||
tar-stream@^2.1.4:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
|
||||
integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
|
||||
dependencies:
|
||||
bl "^4.0.3"
|
||||
end-of-stream "^1.4.1"
|
||||
fs-constants "^1.0.0"
|
||||
inherits "^2.0.3"
|
||||
readable-stream "^3.1.1"
|
||||
|
||||
tar@^6.1.11:
|
||||
version "6.1.15"
|
||||
resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.15.tgz#c9738b0b98845a3b344d334b8fa3041aaba53a69"
|
||||
@@ -8123,11 +7758,6 @@ url@0.10.3:
|
||||
punycode "1.3.2"
|
||||
querystring "0.2.0"
|
||||
|
||||
util-deprecate@^1.0.1, util-deprecate@~1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||
|
||||
util@^0.12.4:
|
||||
version "0.12.5"
|
||||
resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc"
|
||||
@@ -8402,11 +8032,6 @@ yargs-parser@20.x, yargs-parser@^20.2.2:
|
||||
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz"
|
||||
integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
|
||||
|
||||
yargs-parser@^21.1.1:
|
||||
version "21.1.1"
|
||||
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
|
||||
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
|
||||
|
||||
yargs@^16.2.0:
|
||||
version "16.2.0"
|
||||
resolved "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz"
|
||||
@@ -8420,19 +8045,6 @@ yargs@^16.2.0:
|
||||
y18n "^5.0.5"
|
||||
yargs-parser "^20.2.2"
|
||||
|
||||
yargs@^17.7.2:
|
||||
version "17.7.2"
|
||||
resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"
|
||||
integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==
|
||||
dependencies:
|
||||
cliui "^8.0.1"
|
||||
escalade "^3.1.1"
|
||||
get-caller-file "^2.0.5"
|
||||
require-directory "^2.1.1"
|
||||
string-width "^4.2.3"
|
||||
y18n "^5.0.5"
|
||||
yargs-parser "^21.1.1"
|
||||
|
||||
yarn-audit-fix@^9.3.8:
|
||||
version "9.3.12"
|
||||
resolved "https://registry.yarnpkg.com/yarn-audit-fix/-/yarn-audit-fix-9.3.12.tgz#cc34e87aa080bace32f2f105be6b581a3cb6eb24"
|
||||
|
||||
Reference in New Issue
Block a user