Compare commits

..

9 Commits

Author SHA1 Message Date
frostebite
4233b08bae chore: rebuild dist after rebase onto main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 18:04:51 +01:00
Daniel Lupiañez Casares
2badde1790 Use latests unity version for Xcode compatibility with modern versions 2026-05-03 18:04:17 +01:00
Daniel Lupiañez Casares
eba50f7627 Tests for Build Parameters 2026-05-03 18:04:17 +01:00
Daniel Lupiañez Casares
f8b20890d9 Adds tests for Inputs 2026-05-03 18:04:16 +01:00
Daniel Lupiañez Casares
b57598a959 Uses useHostNetwork in docker arguments for the linux command 2026-05-03 18:04:15 +01:00
Daniel Lupiañez Casares
7b2ec07fc1 Adds useHostNetwork to BuildParameters 2026-05-03 18:04:14 +01:00
Daniel Lupiañez Casares
3d3a018c23 Add useHostNetwork to Input class 2026-05-03 18:04:14 +01:00
Daniel Lupiañez Casares
a12e3e829e Add optional argument to action 2026-05-03 18:04:13 +01:00
frostebite
2321712bb4 fix: remove concurrency block from reusable workflow to prevent deadlock
When integrity-check.yml calls validate-orchestrator-integration.yml via
workflow_call, both workflows resolve github.workflow to the same name
("Integrity"), creating identical concurrency groups. GitHub detects this
as a deadlock and cancels the run.

Fix: remove concurrency from the reusable workflow entirely — the caller
already manages concurrency for the group.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 17:18:12 +01:00
82 changed files with 72089 additions and 76299 deletions

5
.eslintignore Normal file
View File

@@ -0,0 +1,5 @@
dist/
lib/
node_modules/
jest.config.js
src/types/

91
.eslintrc.json Normal file
View File

@@ -0,0 +1,91 @@
{
"root": true,
"plugins": ["jest", "@typescript-eslint", "prettier", "unicorn"],
"extends": ["plugin:unicorn/recommended", "plugin:github/recommended", "plugin:prettier/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"extraFileExtensions": [".mjs"],
"ecmaFeatures": {
"impliedStrict": true
},
"project": "./tsconfig.json"
},
"env": {
"node": true,
"es6": true,
"jest/globals": true,
"es2020": true
},
"rules": {
// Error out for code formatting errors
"prettier/prettier": "error",
// Namespaces or sometimes needed
"import/no-namespace": "off",
// Properly format comments
"spaced-comment": ["error", "always"],
"lines-around-comment": [
"error",
{
"beforeBlockComment": true,
"beforeLineComment": true,
"allowBlockStart": true,
"allowObjectStart": true,
"allowArrayStart": true,
"allowClassStart": true,
"ignorePattern": "pragma|ts-ignore"
}
],
// Mandatory spacing
"padding-line-between-statements": [
"error",
{
"blankLine": "always",
"prev": "*",
"next": "return"
},
{
"blankLine": "always",
"prev": "directive",
"next": "*"
},
{
"blankLine": "any",
"prev": "directive",
"next": "directive"
}
],
// Enforce camelCase
"camelcase": "error",
// Allow forOfStatements
"no-restricted-syntax": ["error", "ForInStatement", "LabeledStatement", "WithStatement"],
// Continue is viable in forOf loops in generators
"no-continue": "off",
// From experience, named exports are almost always desired. I got tired of this rule
"import/prefer-default-export": "off",
// Unused vars are useful to keep method signatures consistent and documented
"@typescript-eslint/no-unused-vars": "off",
// For this project only use kebab-case
"unicorn/filename-case": [
"error",
{
"cases": {
"kebabCase": true
}
}
],
// Allow Array.from(set) mitigate TS2569 which would require '--downlevelIteration'
"unicorn/prefer-spread": "off",
// Temp disable to prevent mixing changes with other PRs
"i18n-text/no-en": "off"
},
"overrides": [
{
"files": ["jest.setup.js"],
"rules": {
"import/no-commonjs": "off"
}
}
]
}

View File

@@ -14,7 +14,9 @@ assignees: ''
<!--Steps to reproduce the behavior:-->
- **Expected behavior**
-
**Expected behavior**
<!--A clear and concise description of what you expected to happen.-->

View File

@@ -9,7 +9,8 @@ concurrency:
cancel-in-progress: true
env:
UNITY_LICENSE: "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root>\n <License
UNITY_LICENSE:
"<?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
@@ -35,7 +36,8 @@ env:
jobs:
buildForAllPlatformsUbuntu:
name: "${{ matrix.targetPlatform }} on ${{ matrix.unityVersion}}${{startsWith(matrix.buildProfile, 'Assets') && ' (via Build Profile)' || '' }}"
name:
"${{ matrix.targetPlatform }} on ${{ matrix.unityVersion}}${{startsWith(matrix.buildProfile, 'Assets') && ' (via Build Profile)' || '' }}"
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -198,6 +200,7 @@ jobs:
###########################
- uses: actions/upload-artifact@v4
with:
name: "Build ${{ matrix.targetPlatform }}${{ startsWith(matrix.buildProfile, 'Assets') && ' (via Build Profile)' || '' }} on Ubuntu (${{ matrix.unityVersion }}_il2cpp_${{ matrix.buildWithIl2cpp }}_params_${{ matrix.additionalParameters }})"
name:
"Build ${{ matrix.targetPlatform }}${{ startsWith(matrix.buildProfile, 'Assets') && ' (via Build Profile)' || '' }} on Ubuntu (${{ matrix.unityVersion }}_il2cpp_${{ matrix.buildWithIl2cpp }}_params_${{ matrix.additionalParameters }})"
path: build
retention-days: 14

View File

@@ -23,31 +23,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install package manager (from package.json)
run: |
corepack enable
corepack install
- uses: actions/setup-node@v4
with:
node-version: '18'
- name: Resolve yarn cache folder
id: yarn-config
run: echo "cacheFolder=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- name: Restore yarn install cache (node_modules + cacheFolder + install-state)
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-config.outputs.cacheFolder }}
.yarn/install-state.gz
key: yarn-v2-${{ runner.os }}-node-18-${{ hashFiles('yarn.lock') }}
restore-keys: |
yarn-v2-${{ runner.os }}-node-18-
- name: Install deps
env:
YARN_ENABLE_HARDENED_MODE: 'false'
run: |
case "$(yarn --version)" in 1.*) echo 'expected up-to-date yarn version'; exit 1 ;; esac
yarn install --immutable
- run: yarn
- run: yarn lint
- run: yarn test:ci --coverage
- run: bash <(curl -s https://codecov.io/bash)

View File

@@ -98,12 +98,12 @@ jobs:
# Add git package via manifest
cd test-project
python3 -c "
cat Packages/manifest.json | python3 -c "
import sys, json
manifest = json.load(sys.stdin)
manifest['dependencies']['${{ matrix.name }}'] = '${{ matrix.package }}'
json.dump(manifest, sys.stdout, indent=2)
" < Packages/manifest.json > Packages/manifest.tmp && mv Packages/manifest.tmp Packages/manifest.json
" > Packages/manifest.tmp && mv Packages/manifest.tmp Packages/manifest.json
cd ..
fi
@@ -125,20 +125,18 @@ jobs:
if: always()
run: |
STATUS="${{ steps.build.outcome }}"
{
echo "## ${{ matrix.name }} — ${{ matrix.platform }}"
echo ""
if [ "$STATUS" = "success" ]; then
echo "✅ **PASSED** — Compiled and built successfully"
else
echo "❌ **FAILED** — Build or compilation failed"
fi
echo ""
echo "- Unity: ${{ matrix.unity }}"
echo "- Platform: ${{ matrix.platform }}"
echo "- Source: ${{ matrix.source }}"
echo "- Package: \`${{ matrix.package }}\`"
} >> "$GITHUB_STEP_SUMMARY"
echo "## ${{ matrix.name }} — ${{ matrix.platform }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$STATUS" = "success" ]; then
echo "✅ **PASSED** — Compiled and built successfully" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **FAILED** — Build or compilation failed" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Unity: ${{ matrix.unity }}" >> $GITHUB_STEP_SUMMARY
echo "- Platform: ${{ matrix.platform }}" >> $GITHUB_STEP_SUMMARY
echo "- Source: ${{ matrix.source }}" >> $GITHUB_STEP_SUMMARY
echo "- Package: \`${{ matrix.package }}\`" >> $GITHUB_STEP_SUMMARY
report:
name: Validation Report

View File

@@ -63,28 +63,14 @@ jobs:
repository: game-ci/orchestrator
path: orchestrator-standalone
- name: Install package manager (from package.json)
run: |
corepack enable
corepack install
- uses: actions/setup-node@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Resolve yarn cache folder
id: yarn-config
run: echo "cacheFolder=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- name: Restore yarn install cache (node_modules + cacheFolder + install-state)
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-config.outputs.cacheFolder }}
.yarn/install-state.gz
key: yarn-v2-${{ runner.os }}-node-20-${{ hashFiles('yarn.lock') }}
restore-keys: |
yarn-v2-${{ runner.os }}-node-20-
cache: yarn
- name: Install unity-builder dependencies
run: yarn install --immutable
run: yarn install --frozen-lockfile
- name: Build unity-builder
run: |
@@ -95,12 +81,12 @@ jobs:
- name: Run plugin interface unit tests
run: |
echo "Running orchestrator-plugin unit tests..."
npx vitest run orchestrator-plugin --reporter=verbose
npx jest orchestrator-plugin --verbose --detectOpenHandles --forceExit
- name: Build and pack orchestrator
working-directory: orchestrator-standalone
run: |
yarn install --immutable
yarn install --frozen-lockfile
echo "Building orchestrator..."
npx tsc
echo "✓ orchestrator compiles successfully"
@@ -167,10 +153,6 @@ jobs:
continue-on-error: true
id: orch-branch
- name: Clean corrupted checkout
if: steps.orch-branch.outcome == 'failure'
run: rm -rf .git || true
- name: Fallback to orchestrator main branch
if: steps.orch-branch.outcome == 'failure'
uses: actions/checkout@v4
@@ -178,25 +160,11 @@ jobs:
repository: game-ci/orchestrator
lfs: false
- name: Install package manager (from package.json)
run: |
corepack enable
corepack install
- uses: actions/setup-node@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Resolve yarn cache folder
id: yarn-config
run: echo "cacheFolder=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- name: Restore yarn install cache (node_modules + cacheFolder + install-state)
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-config.outputs.cacheFolder }}
.yarn/install-state.gz
key: yarn-v2-${{ runner.os }}-node-20-${{ hashFiles('yarn.lock') }}
restore-keys: |
yarn-v2-${{ runner.os }}-node-20-
cache: yarn
- name: Set up kubectl
uses: azure/setup-kubectl@v4
@@ -299,20 +267,20 @@ jobs:
- name: Create S3 bucket for tests
run: |
for _ in {1..10}; do
for i in {1..10}; do
if curl -s http://localhost:4566/_localstack/health > /dev/null 2>&1; then break; fi
sleep 1
done
for _ in {1..5}; do
for i in {1..5}; do
if command -v awslocal > /dev/null 2>&1; then
awslocal s3 mb s3://"$AWS_STACK_NAME" 2>&1 && break
awslocal s3 mb s3://$AWS_STACK_NAME 2>&1 && break
else
aws --endpoint-url=http://localhost:4566 s3 mb s3://"$AWS_STACK_NAME" 2>&1 && break
aws --endpoint-url=http://localhost:4566 s3 mb s3://$AWS_STACK_NAME 2>&1 && break
fi
sleep 2
done
- run: yarn install --immutable
- run: yarn install --frozen-lockfile
- name: Build orchestrator
run: |
@@ -324,20 +292,9 @@ jobs:
- name: Run orchestrator unit tests (fast, no infra)
timeout-minutes: 2
run: >-
yarn vitest run
"orchestrator-guid"
"orchestrator-folders"
"task-parameter-serializer"
"follow-log-stream-service"
"runner-availability-service"
"provider-url-parser"
"provider-loader"
"provider-git-manager"
"orchestrator-image"
"orchestrator-hooks"
"orchestrator-github-checks"
"middleware-service"
--reporter=verbose --no-file-parallelism
yarn run test
--testPathPattern="orchestrator-guid|orchestrator-folders|task-parameter-serializer|follow-log-stream-service|runner-availability-service|provider-url-parser|provider-loader|provider-git-manager|orchestrator-image|orchestrator-hooks|orchestrator-github-checks|middleware-service"
--verbose --detectOpenHandles --forceExit --runInBand
# --- K8s cluster setup ---
- name: Clean up disk space before K8s tests
@@ -357,7 +314,7 @@ jobs:
--network orchestrator-net \
--wait
kubectl config current-context | cat
echo "LOCALSTACK_IP=$LOCALSTACK_IP" >> "$GITHUB_ENV"
echo "LOCALSTACK_IP=$LOCALSTACK_IP" >> $GITHUB_ENV
- name: Verify cluster readiness and MiniStack connectivity
timeout-minutes: 2
@@ -370,15 +327,15 @@ jobs:
kubectl get nodes
LOCALSTACK_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' localstack-main 2>/dev/null || echo "")
kubectl run test-localstack --image=curlimages/curl --rm -i --restart=Never --timeout=30s -- \
curl -v --max-time 10 http://"${LOCALSTACK_IP}":4566/_localstack/health 2>&1 | head -30 || \
curl -v --max-time 10 http://${LOCALSTACK_IP}:4566/_localstack/health 2>&1 | head -30 || \
echo "Cluster connectivity test - MiniStack may not be accessible from k3d"
- name: Clean up K8s resources before tests
run: |
source /tmp/cleanup-functions.sh
k8s_resource_cleanup
for _ in {1..30}; do
PVC_COUNT=$(kubectl get pvc -n default 2>/dev/null | grep -c "unity-builder-pvc-" || echo "0")
for i in {1..30}; do
PVC_COUNT=$(kubectl get pvc -n default 2>/dev/null | grep "unity-builder-pvc-" | wc -l || echo "0")
if [ "$PVC_COUNT" -eq 0 ]; then echo "All PVCs deleted"; break; fi
sleep 1
done
@@ -387,7 +344,7 @@ jobs:
# --- K8s Test 1: orchestrator-image ---
- name: Run orchestrator-image test (K8s)
timeout-minutes: 10
run: yarn run test "orchestrator-image" --no-file-parallelism
run: yarn run test "orchestrator-image" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -411,7 +368,7 @@ jobs:
# --- K8s Test 2: orchestrator-kubernetes ---
- name: Run orchestrator-kubernetes test
timeout-minutes: 30
run: yarn run test "orchestrator-kubernetes" --no-file-parallelism
run: yarn run test "orchestrator-kubernetes" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -443,7 +400,7 @@ jobs:
# --- K8s Test 3: orchestrator-s3-steps ---
- name: Run orchestrator-s3-steps test (K8s)
timeout-minutes: 30
run: yarn run test "orchestrator-s3-steps" --no-file-parallelism
run: yarn run test "orchestrator-s3-steps" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -476,7 +433,7 @@ jobs:
- name: Run orchestrator-end2end-caching test (K8s)
timeout-minutes: 60
continue-on-error: true
run: yarn run test "orchestrator-end2end-caching" --no-file-parallelism
run: yarn run test "orchestrator-end2end-caching" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -515,7 +472,7 @@ jobs:
- name: Run orchestrator-end2end-retaining test (K8s)
timeout-minutes: 60
continue-on-error: true
run: yarn run test "orchestrator-end2end-retaining" --no-file-parallelism
run: yarn run test "orchestrator-end2end-retaining" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -575,10 +532,6 @@ jobs:
continue-on-error: true
id: orch-branch
- name: Clean corrupted checkout
if: steps.orch-branch.outcome == 'failure'
run: rm -rf .git || true
- name: Fallback to orchestrator main branch
if: steps.orch-branch.outcome == 'failure'
uses: actions/checkout@v4
@@ -586,25 +539,11 @@ jobs:
repository: game-ci/orchestrator
lfs: false
- name: Install package manager (from package.json)
run: |
corepack enable
corepack install
- uses: actions/setup-node@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Resolve yarn cache folder
id: yarn-config
run: echo "cacheFolder=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- name: Restore yarn install cache (node_modules + cacheFolder + install-state)
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-config.outputs.cacheFolder }}
.yarn/install-state.gz
key: yarn-v2-${{ runner.os }}-node-20-${{ hashFiles('yarn.lock') }}
restore-keys: |
yarn-v2-${{ runner.os }}-node-20-
cache: yarn
- name: Define cleanup functions
run: |
@@ -649,16 +588,16 @@ jobs:
- name: Create S3 bucket for tests
run: |
for _ in {1..5}; do
for i in {1..5}; do
if command -v awslocal > /dev/null 2>&1; then
awslocal s3 mb s3://"$AWS_STACK_NAME" 2>&1 && break
awslocal s3 mb s3://$AWS_STACK_NAME 2>&1 && break
else
aws --endpoint-url=http://localhost:4566 s3 mb s3://"$AWS_STACK_NAME" 2>&1 && break
aws --endpoint-url=http://localhost:4566 s3 mb s3://$AWS_STACK_NAME 2>&1 && break
fi
sleep 2
done
- run: yarn install --immutable
- run: yarn install --frozen-lockfile
- name: Build orchestrator
run: |
@@ -669,7 +608,7 @@ jobs:
# --- AWS Test 1: orchestrator-image ---
- name: Run orchestrator-image test (AWS)
timeout-minutes: 10
run: yarn run test "orchestrator-image" --no-file-parallelism
run: yarn run test "orchestrator-image" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -687,7 +626,7 @@ jobs:
# --- AWS Test 2: orchestrator-environment ---
- name: Run orchestrator-environment test (AWS)
timeout-minutes: 30
run: yarn run test "orchestrator-environment" --no-file-parallelism
run: yarn run test "orchestrator-environment" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -705,7 +644,7 @@ jobs:
# --- AWS Test 3: orchestrator-s3-steps ---
- name: Run orchestrator-s3-steps test (AWS)
timeout-minutes: 30
run: yarn run test "orchestrator-s3-steps" --no-file-parallelism
run: yarn run test "orchestrator-s3-steps" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -723,7 +662,7 @@ jobs:
# --- AWS Test 4: orchestrator-hooks ---
- name: Run orchestrator-hooks test (AWS)
timeout-minutes: 30
run: yarn run test "orchestrator-hooks" --no-file-parallelism
run: yarn run test "orchestrator-hooks" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -741,7 +680,7 @@ jobs:
# --- AWS Test 5: orchestrator-caching ---
- name: Run orchestrator-caching test (AWS)
timeout-minutes: 60
run: yarn run test "orchestrator-caching" --no-file-parallelism
run: yarn run test "orchestrator-caching" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -759,7 +698,7 @@ jobs:
# --- AWS Test 6: orchestrator-locking-core ---
- name: Run orchestrator-locking-core test (AWS)
timeout-minutes: 60
run: yarn run test "orchestrator-locking-core" --no-file-parallelism
run: yarn run test "orchestrator-locking-core" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -777,7 +716,7 @@ jobs:
# --- AWS Test 7: orchestrator-locking-get-locked ---
- name: Run orchestrator-locking-get-locked test (AWS)
timeout-minutes: 60
run: yarn run test "orchestrator-locking-get-locked" --no-file-parallelism
run: yarn run test "orchestrator-locking-get-locked" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -801,7 +740,7 @@ jobs:
- name: Run orchestrator-end2end-caching test (AWS)
timeout-minutes: 60
continue-on-error: true
run: yarn run test "orchestrator-end2end-caching" --no-file-parallelism
run: yarn run test "orchestrator-end2end-caching" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -820,7 +759,7 @@ jobs:
- name: Run orchestrator-end2end-retaining test (AWS)
timeout-minutes: 60
continue-on-error: true
run: yarn run test "orchestrator-end2end-retaining" --no-file-parallelism
run: yarn run test "orchestrator-end2end-retaining" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -839,7 +778,7 @@ jobs:
- name: Run orchestrator-end2end-locking test (AWS)
timeout-minutes: 60
continue-on-error: true
run: yarn run test "orchestrator-end2end-locking" --no-file-parallelism
run: yarn run test "orchestrator-end2end-locking" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -884,10 +823,6 @@ jobs:
continue-on-error: true
id: orch-branch
- name: Clean corrupted checkout
if: steps.orch-branch.outcome == 'failure'
run: rm -rf .git || true
- name: Fallback to orchestrator main branch
if: steps.orch-branch.outcome == 'failure'
uses: actions/checkout@v4
@@ -895,25 +830,11 @@ jobs:
repository: game-ci/orchestrator
lfs: false
- name: Install package manager (from package.json)
run: |
corepack enable
corepack install
- uses: actions/setup-node@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Resolve yarn cache folder
id: yarn-config
run: echo "cacheFolder=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- name: Restore yarn install cache (node_modules + cacheFolder + install-state)
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-config.outputs.cacheFolder }}
.yarn/install-state.gz
key: yarn-v2-${{ runner.os }}-node-20-${{ hashFiles('yarn.lock') }}
restore-keys: |
yarn-v2-${{ runner.os }}-node-20-
cache: yarn
- name: Define cleanup functions
run: |
@@ -958,16 +879,16 @@ jobs:
- name: Create S3 bucket for tests
run: |
for _ in {1..5}; do
for i in {1..5}; do
if command -v awslocal > /dev/null 2>&1; then
awslocal s3 mb s3://"$AWS_STACK_NAME" 2>&1 && break
awslocal s3 mb s3://$AWS_STACK_NAME 2>&1 && break
else
aws --endpoint-url=http://localhost:4566 s3 mb s3://"$AWS_STACK_NAME" 2>&1 && break
aws --endpoint-url=http://localhost:4566 s3 mb s3://$AWS_STACK_NAME 2>&1 && break
fi
sleep 2
done
- run: yarn install --immutable
- run: yarn install --frozen-lockfile
- name: Build orchestrator
run: |
@@ -977,7 +898,7 @@ jobs:
# --- Local Docker Test 1: orchestrator-image ---
- name: Run orchestrator-image test (local-docker)
timeout-minutes: 10
run: yarn run test "orchestrator-image" --no-file-parallelism
run: yarn run test "orchestrator-image" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -995,7 +916,7 @@ jobs:
# --- Local Docker Test 2: orchestrator-hooks ---
- name: Run orchestrator-hooks test (local-docker)
timeout-minutes: 30
run: yarn run test "orchestrator-hooks" --no-file-parallelism
run: yarn run test "orchestrator-hooks" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -1013,7 +934,7 @@ jobs:
# --- Local Docker Test 3: orchestrator-local-persistence ---
- name: Run orchestrator-local-persistence test (local-docker)
timeout-minutes: 30
run: yarn run test "orchestrator-local-persistence" --no-file-parallelism
run: yarn run test "orchestrator-local-persistence" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -1031,7 +952,7 @@ jobs:
# --- Local Docker Test 4: orchestrator-caching ---
- name: Run orchestrator-caching test (local-docker)
timeout-minutes: 30
run: yarn run test "orchestrator-caching" --no-file-parallelism
run: yarn run test "orchestrator-caching" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -1049,7 +970,7 @@ jobs:
# --- Local Docker Test 5: orchestrator-github-checks ---
- name: Run orchestrator-github-checks test (local-docker)
timeout-minutes: 30
run: yarn run test "orchestrator-github-checks" --no-file-parallelism
run: yarn run test "orchestrator-github-checks" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -1067,7 +988,7 @@ jobs:
# --- Local Docker Test 6: orchestrator-locking-core (with S3) ---
- name: Run orchestrator-locking-core test (local-docker + S3)
timeout-minutes: 30
run: yarn run test "orchestrator-locking-core" --no-file-parallelism
run: yarn run test "orchestrator-locking-core" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -1085,7 +1006,7 @@ jobs:
# --- Local Docker Test 7: orchestrator-locking-get-locked (with S3) ---
- name: Run orchestrator-locking-get-locked test (local-docker + S3)
timeout-minutes: 30
run: yarn run test "orchestrator-locking-get-locked" --no-file-parallelism
run: yarn run test "orchestrator-locking-get-locked" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -1103,7 +1024,7 @@ jobs:
# --- Local Docker Test 8: orchestrator-s3-steps (with S3) ---
- name: Run orchestrator-s3-steps test (local-docker + S3)
timeout-minutes: 30
run: yarn run test "orchestrator-s3-steps" --no-file-parallelism
run: yarn run test "orchestrator-s3-steps" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -1122,7 +1043,7 @@ jobs:
- name: Run orchestrator-end2end-caching test (local-docker + S3)
timeout-minutes: 60
continue-on-error: true
run: yarn run test "orchestrator-end2end-caching" --no-file-parallelism
run: yarn run test "orchestrator-end2end-caching" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
@@ -1167,10 +1088,6 @@ jobs:
continue-on-error: true
id: orch-branch
- name: Clean corrupted checkout
if: steps.orch-branch.outcome == 'failure'
run: rm -rf .git || true
- name: Fallback to orchestrator main branch
if: steps.orch-branch.outcome == 'failure'
uses: actions/checkout@v4
@@ -1178,25 +1095,11 @@ jobs:
repository: game-ci/orchestrator
lfs: false
- name: Install package manager (from package.json)
run: |
corepack enable
corepack install
- uses: actions/setup-node@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Resolve yarn cache folder
id: yarn-config
run: echo "cacheFolder=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- name: Restore yarn install cache (node_modules + cacheFolder + install-state)
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-config.outputs.cacheFolder }}
.yarn/install-state.gz
key: yarn-v2-${{ runner.os }}-node-20-${{ hashFiles('yarn.lock') }}
restore-keys: |
yarn-v2-${{ runner.os }}-node-20-
cache: yarn
- name: Initial disk space cleanup
run: |
@@ -1228,16 +1131,16 @@ jobs:
- name: Create S3 bucket for tests
run: |
for _ in {1..5}; do
for i in {1..5}; do
if command -v awslocal > /dev/null 2>&1; then
awslocal s3 mb s3://"$AWS_STACK_NAME" 2>&1 && break
awslocal s3 mb s3://$AWS_STACK_NAME 2>&1 && break
else
aws --endpoint-url=http://localhost:4566 s3 mb s3://"$AWS_STACK_NAME" 2>&1 && break
aws --endpoint-url=http://localhost:4566 s3 mb s3://$AWS_STACK_NAME 2>&1 && break
fi
sleep 2
done
- run: yarn install --immutable
- run: yarn install --frozen-lockfile
- name: Build orchestrator
run: |
@@ -1247,7 +1150,7 @@ jobs:
# --- Rclone Test ---
- name: Run orchestrator-rclone-steps test
timeout-minutes: 30
run: yarn run test "orchestrator-rclone-steps" --no-file-parallelism
run: yarn run test "orchestrator-rclone-steps" --detectOpenHandles --forceExit --runInBand
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}

View File

@@ -83,33 +83,15 @@ jobs:
repository: game-ci/orchestrator
path: orchestrator-standalone
- name: Install package manager (from package.json)
run: |
corepack enable
corepack install
- uses: actions/setup-node@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Resolve yarn cache folder
id: yarn-config
run: echo "cacheFolder=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- name: Restore yarn install cache (node_modules + cacheFolder + install-state)
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-config.outputs.cacheFolder }}
.yarn/install-state.gz
key: yarn-v2-${{ runner.os }}-node-20-${{ hashFiles('yarn.lock') }}
restore-keys: |
yarn-v2-${{ runner.os }}-node-20-
cache: yarn
# --- unity-builder compilation and tests ---
- name: Install unity-builder dependencies
env:
YARN_ENABLE_HARDENED_MODE: 'false'
run: |
case "$(yarn --version)" in 1.*) echo 'expected up-to-date yarn version'; exit 1 ;; esac
yarn install --immutable
run: yarn install --frozen-lockfile
- name: Build unity-builder
run: |
@@ -120,7 +102,7 @@ jobs:
- name: Run orchestrator-plugin unit tests
run: |
echo "Running orchestrator-plugin unit tests..."
yarn vitest run orchestrator-plugin
npx jest orchestrator-plugin --verbose --detectOpenHandles --forceExit
# --- Plugin loader without orchestrator ---
- name: Verify plugin loader returns undefined without orchestrator
@@ -151,7 +133,7 @@ jobs:
- name: Build and pack orchestrator
working-directory: orchestrator-standalone
run: |
yarn install --immutable
yarn install --frozen-lockfile
echo "Building orchestrator..."
npx tsc
echo "✓ orchestrator compiles successfully"
@@ -162,7 +144,7 @@ jobs:
working-directory: orchestrator-standalone
run: |
echo "Running orchestrator unit tests..."
yarn vitest run 2>&1 | tail -30
npx jest --no-cache 2>&1 | tail -20
# --- Plugin loader with orchestrator installed ---
- name: Install orchestrator into unity-builder

9
.gitignore vendored
View File

@@ -7,12 +7,3 @@ yarn-error.log
.orig
$LOG_FILE
temp/
# yarn 4 (berry)
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

View File

@@ -1,7 +0,0 @@
#!/usr/bin/env sh
yarn lint-staged
yarn typecheck
if command -v gitleaks >/dev/null 2>&1; then
gitleaks protect --staged --no-banner --redact
fi

View File

@@ -1,17 +0,0 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"proseWrap": "preserve",
"sortPackageJson": false,
"ignorePatterns": [
"**/node_modules/**",
"**/dist/**",
"**/coverage/**",
"**/.yarn/**",
"default-build-script/**",
"test-runner/**",
"platforms/**"
]
}

View File

@@ -1,58 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "vitest", "unicorn", "oxc"],
"categories": {
"correctness": "error",
"suspicious": "error",
"perf": "error"
},
"rules": {
"vitest/require-mock-type-parameters": "off",
"vitest/valid-title": "off",
"vitest/valid-describe-callback": "off",
"vitest/expect-expect": "off",
"vitest/no-conditional-tests": "off",
"vitest/no-conditional-expect": "off",
"vitest/require-to-throw-message": "off",
"vitest/no-disabled-tests": "warn",
"unicorn/prefer-array-flat-map": "warn",
"typescript/no-explicit-any": "warn",
"typescript/ban-ts-comment": "off",
"typescript/no-namespace": "off",
"typescript/no-extraneous-class": "off",
"no-bitwise": "off",
"no-shadow": "off",
"no-await-in-loop": "off",
"no-underscore-dangle": "off",
"unicorn/no-array-sort": "off",
"unicorn/prefer-set-has": "off",
"unicorn/consistent-function-scoping": "off",
"unicorn/no-useless-spread": "warn",
"eslint/preserve-caught-error": "warn",
"oxc/no-map-spread": "warn"
},
"overrides": [
{
"files": ["**/*.test.ts", "**/*.spec.ts"],
"rules": {
"typescript/no-explicit-any": "off",
"no-unused-vars": "off"
}
}
],
"env": {
"browser": false,
"node": true,
"es2024": true,
"vitest/globals": true
},
"ignorePatterns": [
"**/node_modules/**",
"**/dist/**",
"**/coverage/**",
"**/.yarn/**",
"default-build-script/**",
"test-runner/**",
"platforms/**"
]
}

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
**/node_modules/**
**/dist/**

7
.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"proseWrap": "always"
}

3
.yarnrc Normal file
View File

@@ -0,0 +1,3 @@
save-prefix "^"
--install.audit true
--add.audit true

View File

@@ -1,10 +0,0 @@
approvedGitRepositories:
- '**'
compressionLevel: mixed
enableGlobalCache: false
enableHardenedMode: false
nodeLinker: node-modules

View File

@@ -9,7 +9,8 @@ inputs:
unityVersion:
required: false
default: 'auto'
description: 'Version of unity to use for building the project. Use "auto" to get from your ProjectSettings/ProjectVersion.txt'
description:
'Version of unity to use for building the project. Use "auto" to get from your ProjectSettings/ProjectVersion.txt'
customImage:
required: false
default: ''
@@ -123,7 +124,8 @@ inputs:
chownFilesTo:
required: false
default: ''
description: 'User and optionally group (user or user:group or uid:gid) to give ownership of the resulting build artifacts'
description:
'User and optionally group (user or user:group or uid:gid) to give ownership of the resulting build artifacts'
dockerCpuLimit:
required: false
default: ''
@@ -168,14 +170,6 @@ inputs:
default: ''
required: false
description: 'The Unity licensing server address to use for activating Unity.'
unityLicensingToolset:
default: ''
required: false
description:
'Optional toolset identifier for Unity floating-license servers that host multiple toolsets (e.g.
"LicenseServer_1234567890_3"). When set, written to services-config.json so the Licensing Client
requests entitlements from the named toolset instead of relying on the server-side default. Leave
empty to preserve previous behavior.'
dockerWorkspacePath:
default: '/github/workspace'
required: false
@@ -186,10 +180,6 @@ inputs:
default: 'false'
required: false
description: 'Skip the activation/deactivation of Unity. This assumes Unity is already activated.'
linux64RemoveExecutableExtension:
default: 'false'
required: false
description: 'When building for StandaloneLinux64, remove the default file extension of `.x86_64`. Set to true to restore the extensionless behavior from v4.'
outputs:
volume:
@@ -207,5 +197,5 @@ branding:
icon: 'box'
color: 'gray-dark'
runs:
using: 'node24'
using: 'node20'
main: 'dist/index.js'

134257
dist/index.js generated vendored

File diff suppressed because one or more lines are too long

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

899
dist/licenses.txt generated vendored

File diff suppressed because it is too large Load Diff

View File

@@ -71,12 +71,8 @@ if [[ "$RUN_AS_HOST_USER" == "true" ]]; then
# Don't stop on error when running our scripts as error handling is baked in
set +e
# Switch to the host user so we can create files with the correct ownership.
# Pass HOME/USER explicitly so the Unity Licensing Client (which writes to
# ~/.config/unity3d) resolves a real, writable home directory rather than
# falling back to /home/UNKNOWN when getpwuid() inside the container has no
# entry for the host UID. -p preserves the rest of the env from root.
su -p $USERNAME -c "HOME=/home/$USERNAME USER=$USERNAME LOGNAME=$USERNAME $SHELL -c 'source /steps/runsteps.sh'"
# Switch to the host user so we can create files with the correct ownership
su $USERNAME -c "$SHELL -c 'source /steps/runsteps.sh'"
else
echo "Running as root"

11
jest.ci.config.js Normal file
View File

@@ -0,0 +1,11 @@
const base = require('./jest.config.js');
module.exports = {
...base,
forceExit: true,
detectOpenHandles: true,
testTimeout: 120000,
maxWorkers: 1,
};

30
jest.config.js Normal file
View File

@@ -0,0 +1,30 @@
module.exports = {
// Automatically clear mock calls and instances between every test
clearMocks: true,
// An array of file extensions your modules use
moduleFileExtensions: ['js', 'ts'],
// The test environment that will be used for testing
testEnvironment: 'node',
// The glob patterns Jest uses to detect test files
testMatch: ['**/*.test.ts'],
// This option allows use of a custom test runner
testRunner: 'jest-circus/runner',
// A map with regular expressions for transformers to paths
transform: {
'^.+\\.ts$': 'ts-jest',
},
// Indicates whether each individual test should be reported during the run
verbose: true,
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
modulePathIgnorePatterns: ['<rootDir>/lib/', '<rootDir>/dist/'],
// Use jest.setup.js to polyfill fetch for all tests
setupFiles: ['<rootDir>/jest.setup.js'],
};

31
lefthook.yml Normal file
View File

@@ -0,0 +1,31 @@
# EXAMPLE USAGE
# Refer for explanation to following link:
# https://github.com/evilmartians/lefthook/blob/master/docs/full_guide.md
#
color: true
extends: {}
pre-commit:
parallel: true
commands:
format documents:
glob: '*.{md,mdx}'
run: yarn prettier --write {staged_files}
format configs:
glob: '*.{json,yml,yaml}'
run: yarn prettier --write {staged_files}
format code:
glob: '*.{js,jsx,ts,tsx}'
exclude: 'dist/'
run: yarn prettier --write {staged_files} && yarn eslint {staged_files} && git add {staged_files}
run tests:
glob: '*.{js,jsx,ts,tsx}'
exclude: 'dist/'
run: yarn jest --passWithNoTests --findRelatedTests {staged_files}
build distributables:
skip: ['merge', 'rebase']
run: yarn build && git add dist
make shell script executable:
glob: '*.sh'
run: git update-index --chmod=+x

View File

@@ -1,6 +0,0 @@
[tools]
node = "20.18.0"
yarn = "4.14.1"
actionlint = "latest"
shellcheck = "latest"
gitleaks = "latest"

View File

@@ -7,67 +7,58 @@
"author": "Webber <webber@takken.io>",
"license": "MIT",
"scripts": {
"prepare": "husky",
"prepare": "lefthook install",
"build": "yarn && tsc && ncc build lib --source-map --license licenses.txt",
"test": "node scripts/ensure-husky.mjs && vitest run",
"test:watch": "vitest",
"test:ci": "vitest run",
"coverage": "vitest run --coverage",
"lint": "yarn oxlint --report-unused-disable-directives",
"format": "oxfmt --write",
"format:check": "oxfmt --check",
"typecheck": "tsc --noEmit",
"typecheck:tsgo": "tsgo --noEmit",
"setup:hooks": "node scripts/ensure-husky.mjs"
},
"lint-staged": {
"*.@(ts|tsx|mts|js|jsx|mjs|cjs)": [
"oxlint --fix --quiet",
"oxfmt --write"
],
"*.@(json|jsonc|json5|md|mdx|yaml|yml|css|scss|sass|html|toml)": "oxfmt --write",
".github/workflows/*.@(yml|yaml)": "actionlint"
"lint": "prettier --check \"src/**/*.{js,ts}\" && eslint src/**/*.ts",
"format": "prettier --write \"src/**/*.{js,ts}\"",
"test": "jest",
"test:ci": "jest --config=jest.ci.config.js --runInBand"
},
"engines": {
"node": ">=18.x"
},
"dependencies": {
"@actions/cache": "^4.1.0",
"@actions/cache": "^4.0.0",
"@actions/core": "^1.11.1",
"@actions/exec": "^1.1.1",
"@actions/github": "^6.0.1",
"@actions/github": "^6.0.0",
"commander": "^9.0.0",
"commander-ts": "^0.2.0",
"md5": "^2.3.0",
"nanoid": "^3.3.12",
"semver": "^7.7.4",
"reflect-metadata": "^0.1.13",
"nanoid": "^3.3.1",
"semver": "^7.5.2",
"ts-md5": "^1.3.1",
"unity-changeset": "^3.1.0",
"yaml": "^2.8.4"
"yaml": "^2.2.2"
},
"devDependencies": {
"@types/jest": "^27.4.1",
"@types/node": "^17.0.23",
"@types/semver": "^7.3.9",
"@typescript/native-preview": "^7.0.0-dev.20260505.1",
"@typescript-eslint/parser": "4.8.1",
"@vercel/ncc": "^0.36.1",
"@vitest/coverage-istanbul": "^4.1.5",
"cross-env": "^7.0.3",
"eslint": "^10.3.0",
"eslint-plugin-unicorn": "^64.0.0",
"husky": "9",
"eslint": "^7.23.0",
"eslint-config-prettier": "8.1.0",
"eslint-plugin-github": "^4.1.1",
"eslint-plugin-jest": "24.1.3",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-unicorn": "28.0.2",
"jest": "^27.5.1",
"jest-circus": "^27.5.1",
"jest-fail-on-console": "^3.0.2",
"js-yaml": "^4.1.0",
"lint-staged": "^16.4.0",
"lefthook": "^1.6.1",
"node-fetch": "2",
"oxfmt": "^0.48.0",
"oxlint": "^1.63.0",
"prettier": "^2.5.1",
"ts-jest": "^27.1.3",
"ts-node": "10.8.1",
"typescript": "4.7.4",
"vite": "^7",
"vitest": "^4",
"yarn-audit-fix": "^9.3.8"
},
"packageManager": "yarn@4.14.1",
"dependenciesMeta": {
"lefthook": {
"built": true
}
"volta": {
"node": "20.5.1",
"yarn": "1.22.19"
}
}

View File

@@ -1,55 +0,0 @@
#!/usr/bin/env node
// Self-heals husky git hooks before local dev workflows.
//
// Why this exists: Yarn 4 skips lifecycle scripts (`prepare`, `postinstall`) on
// no-op installs, so `yarn install --immutable` does NOT reinstall hooks once
// `.husky/_/` has been wiped. `.husky/_/` is gitignored, so it is also missing
// in fresh worktrees and after `git clean -fdx`. Without this guard, commits
// silently skip the pre-commit hook (git treats a missing hook file as "no hook").
//
// Behaviour: ~20 ms no-op when hooks are already installed. Skipped in CI and
// when HUSKY=0. Fails loudly (non-zero exit) on real install errors so the
// caller stops before commits are made without hooks.
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
if (process.env.CI || process.env.HUSKY === '0') process.exit(0);
const expectedHooksPath = '.husky/_';
const sentinelHook = '.husky/_/pre-commit';
// husky 9.1+ ships bin.js; husky 9.0 ships bin.mjs. Try both.
const huskyBin = ['node_modules/husky/bin.js', 'node_modules/husky/bin.mjs'].find(existsSync);
let configuredHooksPath = '';
try {
configuredHooksPath = execSync('git config --get core.hooksPath', {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
// not a git repo or config unset — fall through and try to install
}
if (configuredHooksPath === expectedHooksPath && existsSync(sentinelHook)) {
process.exit(0);
}
if (!huskyBin) {
// husky not installed yet (yarn install hasn't run) — silent no-op
process.exit(0);
}
console.log('· installing git hooks (husky self-heal)…');
try {
execSync(`node ${huskyBin}`, { stdio: 'inherit' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(
`\n❌ husky install failed: ${message}\n\n` +
` git pre-commit hooks are NOT installed; commits will skip lint/format/tests.\n` +
` Fix the underlying error above, then run \`yarn setup:hooks\` to retry.\n` +
` To bypass this guard temporarily (NOT recommended): HUSKY=0 yarn <cmd>.\n`,
);
process.exit(1);
}

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
/**
* Integration wiring tests for the plugin lifecycle in index.ts
*
@@ -10,77 +9,74 @@ import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vite
* - When providerStrategy is non-local without a plugin, an error is thrown
*/
import { BuildParameters, Docker } from './model';
import * as core from '@actions/core';
import { BuildParameters } from './model';
// ---------------------------------------------------------------------------
// Mock plugin
// ---------------------------------------------------------------------------
// `vi.mock` hoists to the top of the module, so any factory references must
// be hoisted with `vi.hoisted` to be defined at mock-evaluation time.
const { mockPlugin, mockLoadPlugin } = vi.hoisted(() => {
const plugin = {
initialize: vi.fn().mockResolvedValue(undefined),
canHandleBuild: vi.fn().mockReturnValue(false),
handleBuild: vi.fn().mockResolvedValue({ exitCode: 0 }),
beforeLocalBuild: vi.fn().mockResolvedValue(undefined),
afterLocalBuild: vi.fn().mockResolvedValue(undefined),
handlePostBuild: vi.fn().mockResolvedValue(undefined),
};
return {
mockPlugin: plugin,
mockLoadPlugin: vi.fn().mockResolvedValue(plugin),
};
});
const mockPlugin = {
initialize: jest.fn().mockResolvedValue(undefined),
canHandleBuild: jest.fn().mockReturnValue(false),
handleBuild: jest.fn().mockResolvedValue({ exitCode: 0 }),
beforeLocalBuild: jest.fn().mockResolvedValue(undefined),
afterLocalBuild: jest.fn().mockResolvedValue(undefined),
handlePostBuild: jest.fn().mockResolvedValue(undefined),
};
vi.mock('./model/plugin', () => ({
loadPlugin: mockLoadPlugin,
const mockLoadOrchestratorPlugin = jest.fn().mockResolvedValue(mockPlugin);
jest.mock('./model/orchestrator-plugin', () => ({
loadOrchestratorPlugin: mockLoadOrchestratorPlugin,
}));
vi.mock('@actions/core');
vi.mock('./model', () => ({
jest.mock('@actions/core');
jest.mock('./model', () => ({
Action: {
checkCompatibility: vi.fn(),
checkCompatibility: jest.fn(),
workspace: '/workspace',
actionFolder: '/action',
},
BuildParameters: {
create: vi.fn(),
create: jest.fn(),
},
Cache: {
verify: vi.fn(),
verify: jest.fn(),
},
Docker: {
run: vi.fn().mockResolvedValue(0),
run: jest.fn().mockResolvedValue(0),
},
// vitest 4 requires constructor mocks to use regular `function` (or
// `class`); arrow fns aren't valid constructors.
ImageTag: vi.fn(function () {
return { toString: () => 'mock-image:latest' };
}),
ImageTag: jest.fn().mockImplementation(() => ({
toString: () => 'mock-image:latest',
})),
Output: {
setBuildVersion: vi.fn().mockResolvedValue(''),
setAndroidVersionCode: vi.fn().mockResolvedValue(''),
setEngineExitCode: vi.fn().mockResolvedValue(''),
setBuildVersion: jest.fn().mockResolvedValue(''),
setAndroidVersionCode: jest.fn().mockResolvedValue(''),
setEngineExitCode: jest.fn().mockResolvedValue(''),
},
}));
vi.mock('./model/mac-builder', () => ({
jest.mock('./model/cli/cli', () => ({
Cli: {
InitCliMode: jest.fn().mockReturnValue(false),
},
}));
jest.mock('./model/mac-builder', () => ({
__esModule: true,
default: {
run: vi.fn().mockResolvedValue(0),
run: jest.fn().mockResolvedValue(0),
},
}));
vi.mock('./model/platform-setup', () => ({
jest.mock('./model/platform-setup', () => ({
__esModule: true,
default: {
setup: vi.fn().mockResolvedValue(''),
setup: jest.fn().mockResolvedValue(''),
},
}));
const mockedBuildParametersCreate = BuildParameters.create as Mock;
const mockedBuildParametersCreate = BuildParameters.create as jest.Mock;
function createMockBuildParameters(overrides: Record<string, any> = {}) {
return {
@@ -99,12 +95,12 @@ function createMockBuildParameters(overrides: Record<string, any> = {}) {
async function runIndex(overrides: Record<string, any> = {}): Promise<void> {
mockedBuildParametersCreate.mockResolvedValue(createMockBuildParameters(overrides));
// index.ts exports `runMain` for testability (the file used to rely on
// top-level execution + jest's `vi.isolateModules`, but vitest 4 dropped
// that API). Calling the exported function directly is cleaner than
// round-tripping through dynamic imports.
const { runMain } = await import('./index');
await runMain();
return new Promise<void>((resolve) => {
jest.isolateModules(() => {
require('./index');
});
setTimeout(resolve, 100);
});
}
// ---------------------------------------------------------------------------
@@ -116,14 +112,14 @@ describe('index.ts plugin lifecycle wiring', () => {
const originalEnvironment = { ...process.env };
beforeEach(() => {
vi.clearAllMocks();
jest.clearAllMocks();
process.env.GITHUB_WORKSPACE = '/workspace';
Object.defineProperty(process, 'platform', { value: 'linux' });
// Reset plugin to default behavior
mockPlugin.canHandleBuild.mockReturnValue(false);
mockPlugin.handleBuild.mockResolvedValue({ exitCode: 0 });
mockLoadPlugin.mockResolvedValue(mockPlugin);
mockLoadOrchestratorPlugin.mockResolvedValue(mockPlugin);
});
afterEach(() => {
@@ -136,23 +132,16 @@ describe('index.ts plugin lifecycle wiring', () => {
// -----------------------------------------------------------------------
describe('local build with plugin installed', () => {
it('should call lifecycle hooks in order: initialize -> beforeLocalBuild -> [build] -> afterLocalBuild -> handlePostBuild', async () => {
it('should call lifecycle hooks in order: initialize beforeLocalBuild [build] afterLocalBuild handlePostBuild', async () => {
const callOrder: string[] = [];
mockPlugin.initialize.mockImplementation(async () => callOrder.push('initialize'));
mockPlugin.beforeLocalBuild.mockImplementation(async () =>
callOrder.push('beforeLocalBuild'),
);
mockPlugin.beforeLocalBuild.mockImplementation(async () => callOrder.push('beforeLocalBuild'));
mockPlugin.afterLocalBuild.mockImplementation(async () => callOrder.push('afterLocalBuild'));
mockPlugin.handlePostBuild.mockImplementation(async () => callOrder.push('handlePostBuild'));
await runIndex();
expect(callOrder).toEqual([
'initialize',
'beforeLocalBuild',
'afterLocalBuild',
'handlePostBuild',
]);
expect(callOrder).toEqual(['initialize', 'beforeLocalBuild', 'afterLocalBuild', 'handlePostBuild']);
});
it('should pass buildParameters and workspace to initialize', async () => {
@@ -189,6 +178,7 @@ describe('index.ts plugin lifecycle wiring', () => {
describe('plugin handles build (canHandleBuild = true)', () => {
it('should call handleBuild instead of Docker.run', async () => {
const { Docker } = require('./model');
mockPlugin.canHandleBuild.mockReturnValue(true);
mockPlugin.handleBuild.mockResolvedValue({ exitCode: 0 });
@@ -216,6 +206,7 @@ describe('index.ts plugin lifecycle wiring', () => {
describe('fallback to local build', () => {
it('should do a local build when handleBuild returns fallbackToLocal', async () => {
const { Docker } = require('./model');
mockPlugin.canHandleBuild.mockReturnValue(true);
mockPlugin.handleBuild.mockResolvedValue({ exitCode: -1, fallbackToLocal: true });
@@ -234,7 +225,8 @@ describe('index.ts plugin lifecycle wiring', () => {
describe('no plugin installed', () => {
it('should build locally without errors when providerStrategy is local', async () => {
mockLoadPlugin.mockResolvedValue(undefined);
const { Docker } = require('./model');
mockLoadOrchestratorPlugin.mockResolvedValue(undefined);
await runIndex({ providerStrategy: 'local' });
@@ -242,13 +234,12 @@ describe('index.ts plugin lifecycle wiring', () => {
});
it('should error when providerStrategy is non-local and no plugin', async () => {
mockLoadPlugin.mockResolvedValue(undefined);
const core = require('@actions/core');
mockLoadOrchestratorPlugin.mockResolvedValue(undefined);
await runIndex({ providerStrategy: 'aws' });
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('requires @game-ci/orchestrator'),
);
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('requires @game-ci/orchestrator'));
});
});
@@ -258,15 +249,14 @@ describe('index.ts plugin lifecycle wiring', () => {
describe('plugin installed but canHandleBuild returns false with non-local provider', () => {
it('should error when providerStrategy is non-local', async () => {
const core = require('@actions/core');
mockPlugin.canHandleBuild.mockReturnValue(false);
await runIndex({ providerStrategy: 'aws' });
// The plugin is initialized but says it can't handle the build,
// and providerStrategy is not local, so it falls to the error case
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('requires @game-ci/orchestrator'),
);
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('requires @game-ci/orchestrator'));
});
});
});

View File

@@ -1,13 +1,17 @@
import * as core from '@actions/core';
import { Action, BuildParameters, Cache, Docker, ImageTag, Output } from './model';
import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup';
import { Plugin, loadPlugin } from './model/plugin';
import { loadOrchestratorPlugin, OrchestratorPlugin } from './model/orchestrator-plugin';
// Exported so tests can drive the lifecycle directly without depending on
// vitest's module re-loading (which changed in vitest 4).
export async function runMain() {
async function runMain() {
try {
if (Cli.InitCliMode()) {
await Cli.RunCli();
return;
}
Action.checkCompatibility();
Cache.verify();
@@ -15,8 +19,8 @@ export async function runMain() {
const buildParameters = await BuildParameters.create();
const baseImage = new ImageTag(buildParameters);
// Load optional plugin. The default implementation is @game-ci/orchestrator.
const plugin = await loadPlugin();
// Load orchestrator plugin (optional — only needed for remote builds and plugin features)
const plugin = await loadOrchestratorPlugin();
await plugin?.initialize(buildParameters, workspace);
let exitCode = -1;
@@ -58,7 +62,7 @@ async function runLocalBuild(
baseImage: ImageTag,
workspace: string,
actionFolder: string,
plugin?: Plugin,
plugin?: OrchestratorPlugin,
): Promise<number> {
await plugin?.beforeLocalBuild(workspace);
@@ -77,9 +81,4 @@ async function runLocalBuild(
return exitCode;
}
// Auto-run when this module is the entry point. Tests import the file via
// `await import('./index')` purely to register the mock factories and then
// call `runMain()` directly.
if (process.env.NODE_ENV !== 'test') {
runMain();
}
runMain();

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
import { stat } from 'node:fs/promises';
describe('Integrity tests', () => {

9
src/jest.setup.ts Normal file
View File

@@ -0,0 +1,9 @@
import failOnConsole from 'jest-fail-on-console';
// Fail when console logs something inside a test - use spyOn instead
failOnConsole({
shouldFailOnWarn: true,
shouldFailOnError: true,
shouldFailOnLog: true,
shouldFailOnAssert: true,
});

View File

@@ -1,8 +1,7 @@
import { vi } from 'vitest';
// Import this named export into your test file:
import Platform from '../platform';
export const mockGetFromUser = vi.fn().mockResolvedValue({
export const mockGetFromUser = jest.fn().mockResolvedValue({
editorVersion: '',
targetPlatform: Platform.types.Test,
projectPath: '.',

View File

@@ -1,23 +1,22 @@
import { vi } from 'vitest';
/* eslint unicorn/prevent-abbreviations: "off" */
// Import these named export into your test file:
export const mockProjectPath = vi.fn().mockResolvedValue('mockProjectPath');
export const mockIsDirtyAllowed = vi.fn().mockResolvedValue(false);
export const mockBranch = vi.fn().mockResolvedValue('mockBranch');
export const mockHeadRef = vi.fn().mockResolvedValue('mockHeadRef');
export const mockRef = vi.fn().mockResolvedValue('mockRef');
export const mockDetermineVersion = vi.fn().mockResolvedValue('1.2.3');
export const mockGenerateSemanticVersion = vi.fn().mockResolvedValue('2.3.4');
export const mockGenerateTagVersion = vi.fn().mockResolvedValue('1.0');
export const mockParseSemanticVersion = vi.fn().mockResolvedValue({});
export const mockFetch = vi.fn().mockImplementation(() => {});
export const mockGetVersionDescription = vi.fn().mockResolvedValue('1.2-3-g12345678-dirty');
export const mockIsDirty = vi.fn().mockResolvedValue(false);
export const mockGetTag = vi.fn().mockResolvedValue('v1.0');
export const mockHasAnyVersionTags = vi.fn().mockResolvedValue(true);
export const mockGetTotalNumberOfCommits = vi.fn().mockResolvedValue(3);
export const mockGit = vi.fn().mockImplementation(() => {});
export const mockProjectPath = jest.fn().mockResolvedValue('mockProjectPath');
export const mockIsDirtyAllowed = jest.fn().mockResolvedValue(false);
export const mockBranch = jest.fn().mockResolvedValue('mockBranch');
export const mockHeadRef = jest.fn().mockResolvedValue('mockHeadRef');
export const mockRef = jest.fn().mockResolvedValue('mockRef');
export const mockDetermineVersion = jest.fn().mockResolvedValue('1.2.3');
export const mockGenerateSemanticVersion = jest.fn().mockResolvedValue('2.3.4');
export const mockGenerateTagVersion = jest.fn().mockResolvedValue('1.0');
export const mockParseSemanticVersion = jest.fn().mockResolvedValue({});
export const mockFetch = jest.fn().mockImplementation(() => {});
export const mockGetVersionDescription = jest.fn().mockResolvedValue('1.2-3-g12345678-dirty');
export const mockIsDirty = jest.fn().mockResolvedValue(false);
export const mockGetTag = jest.fn().mockResolvedValue('v1.0');
export const mockHasAnyVersionTags = jest.fn().mockResolvedValue(true);
export const mockGetTotalNumberOfCommits = jest.fn().mockResolvedValue(3);
export const mockGit = jest.fn().mockImplementation(() => {});
export default {
projectPath: mockProjectPath,

View File

@@ -1,3 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Versioning > determineBuildVersion > throws for invalid strategy somethingRandom 1`] = `[ValidationError: Versioning strategy should be one of None, Semantic, Tag, Custom.]`;
exports[`Versioning determineBuildVersion throws for invalid strategy somethingRandom 1`] = `"Versioning strategy should be one of None, Semantic, Tag, Custom."`;

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
import path from 'node:path';
import fs from 'node:fs';
import Action from './action';

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
import AndroidVersioning from './android-versioning';
describe('Android Versioning', () => {
@@ -36,9 +35,7 @@ describe('Android Versioning', () => {
});
it('uses the specified api level', () => {
expect(AndroidVersioning.determineSdkManagerParameters('AndroidApiLevel30')).toBe(
'platforms;android-30',
);
expect(AndroidVersioning.determineSdkManagerParameters('AndroidApiLevel30')).toBe('platforms;android-30');
});
});
});

View File

@@ -12,9 +12,7 @@ export default class AndroidVersioning {
static versionToVersionCode(version: string): string {
if (version === 'none') {
core.info(
`Versioning strategy is set to ${version}, so android version code should not be applied.`,
);
core.info(`Versioning strategy is set to ${version}, so android version code should not be applied.`);
return '0';
}
@@ -29,8 +27,7 @@ export default class AndroidVersioning {
// The greatest value Google Plays allows is 2100000000.
// Allow for 3 patch digits, 3 minor digits and 3 major digits.
const versionCode =
parsedVersion.major * 1000000 + parsedVersion.minor * 1000 + parsedVersion.patch;
const versionCode = parsedVersion.major * 1000000 + parsedVersion.minor * 1000 + parsedVersion.patch;
if (versionCode >= 2050000000) {
throw new Error(

View File

@@ -1,4 +1,3 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest';
import Versioning from './versioning';
import UnityVersioning from './unity-versioning';
import AndroidVersioning from './android-versioning';
@@ -10,12 +9,12 @@ 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>';
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
jest.clearAllMocks();
jest.restoreAllMocks();
});
beforeEach(() => {
vi.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37');
jest.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37');
process.env.UNITY_LICENSE = testLicense; // Todo - Don't use process.env directly, that's what the input model class is for.
});
@@ -26,20 +25,20 @@ describe('BuildParameters', () => {
});
it('determines the version only once', async () => {
vi.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37');
jest.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37');
await BuildParameters.create();
await expect(Versioning.determineBuildVersion).toHaveBeenCalledTimes(1);
});
it('determines the unity version only once', async () => {
vi.spyOn(UnityVersioning, 'determineUnityVersion').mockImplementation(() => '2019.2.11f1');
jest.spyOn(UnityVersioning, 'determineUnityVersion').mockImplementation(() => '2019.2.11f1');
await BuildParameters.create();
expect(UnityVersioning.determineUnityVersion).toHaveBeenCalledTimes(1);
});
it('returns the android version code with provided input', async () => {
const mockValue = '42';
vi.spyOn(Input, 'androidVersionCode', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidVersionCode', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidVersionCode: mockValue }),
);
@@ -47,59 +46,49 @@ describe('BuildParameters', () => {
it('returns the android version code from version by default', async () => {
const mockValue = '';
vi.spyOn(Input, 'androidVersionCode', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidVersionCode', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidVersionCode: '1003037' }),
);
});
it('determines the android sdk manager parameters only once', async () => {
vi.spyOn(AndroidVersioning, 'determineSdkManagerParameters').mockImplementation(
() => 'platforms;android-30',
);
jest.spyOn(AndroidVersioning, 'determineSdkManagerParameters').mockImplementation(() => 'platforms;android-30');
await BuildParameters.create();
expect(AndroidVersioning.determineSdkManagerParameters).toHaveBeenCalledTimes(1);
});
it('returns the targetPlatform', async () => {
const mockValue = 'somePlatform';
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ targetPlatform: mockValue }),
);
jest.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ targetPlatform: mockValue }));
});
it('returns the project path', async () => {
const mockValue = 'path/to/project';
vi.spyOn(UnityVersioning, 'determineUnityVersion').mockImplementation(() => '2019.2.11f1');
vi.spyOn(Input, 'projectPath', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ projectPath: mockValue }),
);
jest.spyOn(UnityVersioning, 'determineUnityVersion').mockImplementation(() => '2019.2.11f1');
jest.spyOn(Input, 'projectPath', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ projectPath: mockValue }));
});
it('returns the build profile', async () => {
const mockValue = 'path/to/build_profile.asset';
vi.spyOn(Input, 'buildProfile', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ buildProfile: mockValue }),
);
jest.spyOn(Input, 'buildProfile', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ buildProfile: mockValue }));
});
it('returns the build name', async () => {
const mockValue = 'someBuildName';
vi.spyOn(Input, 'buildName', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ buildName: mockValue }),
);
jest.spyOn(Input, 'buildName', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ buildName: mockValue }));
});
it('returns the build path', async () => {
const mockPath = 'somePath';
const mockPlatform = 'somePlatform';
const expectedBuildPath = `${mockPath}/${mockPlatform}`;
vi.spyOn(Input, 'buildsPath', 'get').mockReturnValue(mockPath);
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockPlatform);
jest.spyOn(Input, 'buildsPath', 'get').mockReturnValue(mockPath);
jest.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockPlatform);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ buildPath: expectedBuildPath }),
);
@@ -109,36 +98,24 @@ describe('BuildParameters', () => {
const mockValue = 'someBuildName';
const mockPlatform = 'somePlatform';
vi.spyOn(Input, 'buildName', 'get').mockReturnValue(mockValue);
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockPlatform);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ buildFile: mockValue }),
);
jest.spyOn(Input, 'buildName', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockPlatform);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ buildFile: mockValue }));
});
test.each`
targetPlatform | expectedExtension | androidExportType | linux64RemoveExecutableExtension
${Platform.types.Android} | ${'.apk'} | ${'androidPackage'} | ${false}
${Platform.types.Android} | ${'.aab'} | ${'androidAppBundle'} | ${true}
${Platform.types.Android} | ${''} | ${'androidStudioProject'} | ${false}
${Platform.types.StandaloneWindows} | ${'.exe'} | ${'n/a'} | ${true}
${Platform.types.StandaloneWindows64} | ${'.exe'} | ${'n/a'} | ${false}
${Platform.types.StandaloneLinux64} | ${'.x86_64'} | ${'n/a'} | ${false}
${Platform.types.StandaloneLinux64} | ${''} | ${'n/a'} | ${true}
targetPlatform | expectedExtension | androidExportType
${Platform.types.Android} | ${'.apk'} | ${'androidPackage'}
${Platform.types.Android} | ${'.aab'} | ${'androidAppBundle'}
${Platform.types.Android} | ${''} | ${'androidStudioProject'}
${Platform.types.StandaloneWindows} | ${'.exe'} | ${'n/a'}
${Platform.types.StandaloneWindows64} | ${'.exe'} | ${'n/a'}
`(
'appends $expectedExtension for $targetPlatform with linux64RemoveExecutableExtension=$linux64RemoveExecutableExtension',
async ({
targetPlatform,
expectedExtension,
androidExportType,
linux64RemoveExecutableExtension,
}) => {
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(targetPlatform);
vi.spyOn(Input, 'buildName', 'get').mockReturnValue(targetPlatform);
vi.spyOn(Input, 'androidExportType', 'get').mockReturnValue(androidExportType);
vi.spyOn(Input, 'linux64RemoveExecutableExtension', 'get').mockReturnValue(
linux64RemoveExecutableExtension,
);
'appends $expectedExtension for $targetPlatform with androidExportType $androidExportType',
async ({ targetPlatform, expectedExtension, androidExportType }) => {
jest.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(targetPlatform);
jest.spyOn(Input, 'buildName', 'get').mockReturnValue(targetPlatform);
jest.spyOn(Input, 'androidExportType', 'get').mockReturnValue(androidExportType);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ buildFile: `${targetPlatform}${expectedExtension}` }),
);
@@ -155,26 +132,22 @@ describe('BuildParameters', () => {
`(
'androidSymbolType is set to $androidSymbolType when targetPlatform is $targetPlatform and input targetSymbolType is $androidSymbolType',
async ({ targetPlatform, androidSymbolType }) => {
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(targetPlatform);
vi.spyOn(Input, 'androidSymbolType', 'get').mockReturnValue(androidSymbolType);
vi.spyOn(Input, 'buildName', 'get').mockReturnValue(targetPlatform);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidSymbolType }),
);
jest.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(targetPlatform);
jest.spyOn(Input, 'androidSymbolType', 'get').mockReturnValue(androidSymbolType);
jest.spyOn(Input, 'buildName', 'get').mockReturnValue(targetPlatform);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ androidSymbolType }));
},
);
it('returns the build method', async () => {
const mockValue = 'Namespace.ClassName.BuildMethod';
vi.spyOn(Input, 'buildMethod', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ buildMethod: mockValue }),
);
jest.spyOn(Input, 'buildMethod', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ buildMethod: mockValue }));
});
it('returns the android keystore name', async () => {
const mockValue = 'keystore.keystore';
vi.spyOn(Input, 'androidKeystoreName', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidKeystoreName', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidKeystoreName: mockValue }),
);
@@ -182,7 +155,7 @@ describe('BuildParameters', () => {
it('returns the android keystore base64-encoded content', async () => {
const mockValue = 'secret';
vi.spyOn(Input, 'androidKeystoreBase64', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidKeystoreBase64', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidKeystoreBase64: mockValue }),
);
@@ -190,7 +163,7 @@ describe('BuildParameters', () => {
it('returns the android keystore pass', async () => {
const mockValue = 'secret';
vi.spyOn(Input, 'androidKeystorePass', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidKeystorePass', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidKeystorePass: mockValue }),
);
@@ -198,7 +171,7 @@ describe('BuildParameters', () => {
it('returns the android keyalias name', async () => {
const mockValue = 'secret';
vi.spyOn(Input, 'androidKeyaliasName', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidKeyaliasName', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidKeyaliasName: mockValue }),
);
@@ -206,7 +179,7 @@ describe('BuildParameters', () => {
it('returns the android keyalias pass', async () => {
const mockValue = 'secret';
vi.spyOn(Input, 'androidKeyaliasPass', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidKeyaliasPass', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidKeyaliasPass: mockValue }),
);
@@ -214,7 +187,7 @@ describe('BuildParameters', () => {
it('returns the android target sdk version', async () => {
const mockValue = 'AndroidApiLevelAuto';
vi.spyOn(Input, 'androidTargetSdkVersion', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidTargetSdkVersion', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidTargetSdkVersion: mockValue }),
);
@@ -222,20 +195,12 @@ describe('BuildParameters', () => {
it('returns the unity licensing server address', async () => {
const mockValue = 'http://example.com';
vi.spyOn(Input, 'unityLicensingServer', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'unityLicensingServer', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ unityLicensingServer: mockValue }),
);
});
it('returns the unity licensing toolset', async () => {
const mockValue = 'LicenseServer_1234567890_3';
vi.spyOn(Input, 'unityLicensingToolset', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ unityLicensingToolset: mockValue }),
);
});
it('throws error when no unity license provider provided', async () => {
delete process.env.UNITY_LICENSE; // Need to delete this as it is set for every test currently
await expect(BuildParameters.create()).rejects.toThrowError();
@@ -245,25 +210,19 @@ describe('BuildParameters', () => {
const mockValue = '123';
delete process.env.UNITY_LICENSE; // Need to delete this as it is set for every test currently
process.env.UNITY_SERIAL = mockValue;
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ unitySerial: mockValue }),
);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ unitySerial: mockValue }));
delete process.env.UNITY_SERIAL;
});
it('returns the custom parameters', async () => {
const mockValue = '-profile SomeProfile -someBoolean -someValue exampleValue';
vi.spyOn(Input, 'customParameters', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ customParameters: mockValue }),
);
jest.spyOn(Input, 'customParameters', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ customParameters: mockValue }));
});
it.each([true, false])('returns the flag for useHostNetwork when %s', async (mockValue) => {
vi.spyOn(Input, 'useHostNetwork', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ useHostNetwork: mockValue }),
);
jest.spyOn(Input, 'useHostNetwork', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ useHostNetwork: mockValue }));
});
});
});

View File

@@ -6,7 +6,7 @@ import UnityVersioning from './unity-versioning';
import Versioning from './versioning';
import { GitRepoReader } from './input-readers/git-repo';
import { GithubCliReader } from './input-readers/github-cli';
import { PluginOptions } from './plugin-options';
import { Cli } from './cli/cli';
import GitHub from './github';
import * as core from '@actions/core';
@@ -18,7 +18,6 @@ class BuildParameters {
public customImage!: string;
public unitySerial!: string;
public unityLicensingServer!: string;
public unityLicensingToolset!: string;
public skipActivation!: string;
public runnerTempPath!: string;
public targetPlatform!: string;
@@ -70,27 +69,11 @@ class BuildParameters {
public dockerWorkspacePath!: string;
static async create(): Promise<BuildParameters> {
const buildFile = this.parseBuildFile(
Input.buildName,
Input.targetPlatform,
Input.androidExportType,
Input.linux64RemoveExecutableExtension,
);
const editorVersion = UnityVersioning.determineUnityVersion(
Input.projectPath,
Input.unityVersion,
);
const buildVersion = await Versioning.determineBuildVersion(
Input.versioningStrategy,
Input.specifiedVersion,
);
const androidVersionCode = AndroidVersioning.determineVersionCode(
buildVersion,
Input.androidVersionCode,
);
const androidSdkManagerParameters = AndroidVersioning.determineSdkManagerParameters(
Input.androidTargetSdkVersion,
);
const buildFile = this.parseBuildFile(Input.buildName, Input.targetPlatform, Input.androidExportType);
const editorVersion = UnityVersioning.determineUnityVersion(Input.projectPath, Input.unityVersion);
const buildVersion = await Versioning.determineBuildVersion(Input.versioningStrategy, Input.specifiedVersion);
const androidVersionCode = AndroidVersioning.determineVersionCode(buildVersion, Input.androidVersionCode);
const androidSdkManagerParameters = AndroidVersioning.determineSdkManagerParameters(Input.androidTargetSdkVersion);
const androidSymbolExportType = Input.androidSymbolType;
if (Platform.isAndroid(Input.targetPlatform)) {
@@ -129,15 +112,13 @@ class BuildParameters {
core.setSecret(`${unitySerial.slice(0, -4)}XXXX`);
}
const providerStrategy =
Input.getInput('providerStrategy') || (PluginOptions.isPluginMode ? 'aws' : 'local');
const providerStrategy = Input.getInput('providerStrategy') || (Cli.isCliMode ? 'aws' : 'local');
return {
editorVersion,
customImage: Input.customImage,
unitySerial,
unityLicensingServer: Input.unityLicensingServer,
unityLicensingToolset: Input.unityLicensingToolset,
skipActivation: Input.skipActivation,
runnerTempPath: Input.runnerTempPath,
targetPlatform: Input.targetPlatform,
@@ -176,27 +157,21 @@ class BuildParameters {
buildPlatform: providerStrategy !== 'local' ? 'linux' : process.platform,
runNumber: Input.runNumber,
branch: Input.branch.replace('/head', '') || (await GitRepoReader.GetBranch()),
githubRepo:
(Input.githubRepo ?? (await GitRepoReader.GetRemote())) || 'game-ci/unity-builder',
githubRepo: (Input.githubRepo ?? (await GitRepoReader.GetRemote())) || 'game-ci/unity-builder',
gitSha: Input.gitSha,
logId: customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 9)(),
buildGuid: `${Input.runNumber}-${Input.targetPlatform.toLowerCase().replace('standalone', '')}-${customAlphabet(
'0123456789abcdefghijklmnopqrstuvwxyz',
4,
)()}`,
isCliMode: PluginOptions.isPluginMode,
isCliMode: Cli.isCliMode,
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath,
};
}
static parseBuildFile(
filename: string,
platform: string,
androidExportType: string,
linux64RemoveExecutableExtension: boolean,
): string {
static parseBuildFile(filename: string, platform: string, androidExportType: string): string {
if (Platform.isWindows(platform)) {
return `${filename}.exe`;
}
@@ -216,10 +191,6 @@ class BuildParameters {
}
}
if (platform === Platform.types.StandaloneLinux64 && !linux64RemoveExecutableExtension) {
return `${filename}.x86_64`;
}
return filename;
}

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest';
import Cache from './cache';
vi.mock('./input');
jest.mock('./input');
describe('Cache', () => {
describe('Verification', () => {

View File

@@ -0,0 +1,45 @@
export class CliFunctionsRepository {
private static targets: any[] = [];
public static PushCliFunction(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
key: string,
description: string,
) {
CliFunctionsRepository.targets.push({
target,
propertyKey,
descriptor,
key,
description,
});
}
public static GetCliFunctions(key: any) {
const results = CliFunctionsRepository.targets.find((x) => x.key === key);
if (results === undefined || results.length === 0) {
throw new Error(`no CLI mode found for ${key}`);
}
return results;
}
public static GetAllCliModes() {
return CliFunctionsRepository.targets.map((x) => {
return {
key: x.key,
description: x.description,
};
});
}
// eslint-disable-next-line no-unused-vars
public static PushCliFunctionSource(cliFunction: any) {}
}
export function CliFunction(key: string, description: string) {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
CliFunctionsRepository.PushCliFunction(target, propertyKey, descriptor, key, description);
};
}

94
src/model/cli/cli.ts Normal file
View File

@@ -0,0 +1,94 @@
import { Command } from 'commander-ts';
import { Input } from '..';
import * as core from '@actions/core';
import { ActionYamlReader } from '../input-readers/action-yaml';
import { CliFunction, CliFunctionsRepository } from './cli-functions-repository';
import { OptionValues } from 'commander';
import { InputKey } from '../input';
export class Cli {
public static options: OptionValues | undefined;
static get isCliMode() {
return Cli.options !== undefined && Cli.options.mode !== undefined && Cli.options.mode !== '';
}
public static query(key: string, alternativeKey: string) {
if (Cli.options && Cli.options[key] !== undefined) {
return Cli.options[key];
}
if (Cli.options && alternativeKey && Cli.options[alternativeKey] !== undefined) {
return Cli.options[alternativeKey];
}
return;
}
public static InitCliMode() {
const program = new Command();
program.version('0.0.1');
const actionYamlReader: ActionYamlReader = new ActionYamlReader();
const properties = Object.getOwnPropertyNames(Input).filter(
(p) => p !== 'length' && p !== 'prototype' && p !== 'name',
);
for (const element of properties) {
program.option(`--${element} <${element}>`, actionYamlReader.GetActionYamlValue(element));
}
program.option(
'-m, --mode <mode>',
CliFunctionsRepository.GetAllCliModes()
.map((x) => `${x.key} (${x.description})`)
.join(` | `),
);
program.option('--populateOverride <populateOverride>', 'should use override query to pull input false by default');
program.option('--cachePushFrom <cachePushFrom>', 'cache push from source folder');
program.option('--cachePushTo <cachePushTo>', 'cache push to caching folder');
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();
return Cli.isCliMode;
}
static async RunCli(): Promise<void> {
const results = CliFunctionsRepository.GetCliFunctions(Cli.options?.mode);
if (!results) {
throw new Error(
`Unknown CLI mode: ${Cli.options?.mode}. Orchestrator CLI features require @game-ci/orchestrator.`,
);
}
core.info(`Entrypoint: ${results.key}`);
Cli.options!.versioning = 'None';
return await results.target[results.propertyKey](Cli.options);
}
@CliFunction(`print-input`, `prints all input`)
private static logInput() {
core.info(`\n`);
core.info(`INPUT:`);
const properties = Object.getOwnPropertyNames(Input).filter(
(p) => p !== 'length' && p !== 'prototype' && p !== 'name',
);
for (const element of properties) {
if (
element in Input &&
Input[element as InputKey] !== undefined &&
Input[element as InputKey] !== '' &&
typeof Input[element as InputKey] !== `function` &&
element !== 'length' &&
element !== 'cliOptions' &&
element !== 'prototype'
) {
core.info(`${element} ${Input[element as InputKey]}`);
}
}
core.info(`\n`);
}
}

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
import Action from './action';
import Docker from './docker';

View File

@@ -17,13 +17,7 @@ class Docker {
let runCommand = '';
switch (process.platform) {
case 'linux':
runCommand = this.getLinuxCommand(
image,
parameters,
overrideCommands,
additionalVariables,
entrypointBash,
);
runCommand = this.getLinuxCommand(image, parameters, overrideCommands, additionalVariables, entrypointBash);
break;
case 'win32':
runCommand = this.getWindowsCommand(image, parameters);

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
import CommandExecutionError from './command-execution-error';
describe('CommandExecutionError', () => {

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
import NotImplementedException from './not-implemented-exception';
describe('NotImplementedException', () => {

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
import ValidationError from './validation-error';
describe('ValidationError', () => {

View File

@@ -1,14 +1,8 @@
import { DockerParameters, StringKeyValuePair } from './shared-types';
class ImageEnvironmentFactory {
public static getEnvVarString(
parameters: DockerParameters,
additionalVariables: StringKeyValuePair[] = [],
) {
const environmentVariables = ImageEnvironmentFactory.getEnvironmentVariables(
parameters,
additionalVariables,
);
public static getEnvVarString(parameters: DockerParameters, additionalVariables: StringKeyValuePair[] = []) {
const environmentVariables = ImageEnvironmentFactory.getEnvironmentVariables(parameters, additionalVariables);
let string = '';
for (const p of environmentVariables) {
if (p.value === '' || p.value === undefined || p.value === null) {
@@ -27,10 +21,7 @@ class ImageEnvironmentFactory {
return string;
}
public static getEnvironmentVariables(
parameters: DockerParameters,
additionalVariables: StringKeyValuePair[] = [],
) {
public static getEnvironmentVariables(parameters: DockerParameters, additionalVariables: StringKeyValuePair[] = []) {
let environmentVariables: StringKeyValuePair[] = [
{ name: 'UNITY_EMAIL', value: process.env.UNITY_EMAIL },
{ name: 'UNITY_PASSWORD', value: process.env.UNITY_PASSWORD },

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
import ImageTag from './image-tag';
describe('ImageTag', () => {
@@ -28,18 +27,15 @@ describe('ImageTag', () => {
expect(image.builderPlatform).toStrictEqual(testImageParameters.builderPlatform);
});
test.each(['2000.0.0f0', '2011.1.11f1', '6000.0.0f1'])(
'accepts %p version format',
(version) => {
expect(
() =>
new ImageTag({
editorVersion: version,
targetPlatform: testImageParameters.targetPlatform,
}),
).not.toThrow();
},
);
test.each(['2000.0.0f0', '2011.1.11f1', '6000.0.0f1'])('accepts %p version format', (version) => {
expect(
() =>
new ImageTag({
editorVersion: version,
targetPlatform: testImageParameters.targetPlatform,
}),
).not.toThrow();
});
test.each(['some version', ''])('throws for incorrect version %p', (editorVersion) => {
const { targetPlatform } = testImageParameters;

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
import * as Index from '.';
interface ExportedModules {

View File

@@ -10,16 +10,4 @@ import Project from './project';
import Unity from './unity';
import Versioning from './versioning';
export {
Action,
BuildParameters,
Cache,
Docker,
Input,
ImageTag,
Output,
Platform,
Project,
Unity,
Versioning,
};
export { Action, BuildParameters, Cache, Docker, Input, ImageTag, Output, Platform, Project, Unity, Versioning };

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest';
import { GitRepoReader } from './git-repo';
import Input from '../input';
@@ -10,15 +9,15 @@ describe(`git repo tests`, () => {
it(`returns valid branch name when using https`, async () => {
const mockValue = 'https://github.com/example/example.git';
vi.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
vi.spyOn(Input, 'getInput').mockReturnValue('not-local');
jest.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
jest.spyOn(Input, 'getInput').mockReturnValue('not-local');
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
});
it(`returns valid branch name when using ssh`, async () => {
const mockValue = 'git@github.com:example/example.git';
vi.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
vi.spyOn(Input, 'getInput').mockReturnValue('not-local');
jest.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
jest.spyOn(Input, 'getInput').mockReturnValue('not-local');
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
});
});

View File

@@ -23,9 +23,7 @@ export class GitRepoReader {
return '';
}
assert(fs.existsSync(`.git`));
const value = (
await GitRepoReader.runCommand(`cd ${Input.projectPath} && git remote -v`)
).replace(/ /g, ``);
const value = (await GitRepoReader.runCommand(`cd ${Input.projectPath} && git remote -v`)).replace(/ /g, ``);
core.info(`value ${value}`);
assert(value.includes('github.com'));

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
import { GithubCliReader } from './github-cli';
import * as core from '@actions/core';

View File

@@ -7,14 +7,7 @@ export function ReadLicense(): string {
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
return '';
}
const pipelineFile = path.join(
__dirname,
`.github`,
`workflows`,
`orchestrator-k8s-pipeline.yml`,
);
const pipelineFile = path.join(__dirname, `.github`, `workflows`, `orchestrator-k8s-pipeline.yml`);
return fs.existsSync(pipelineFile)
? YAML.parse(fs.readFileSync(pipelineFile, 'utf8')).env.UNITY_LICENSE
: '';
return fs.existsSync(pipelineFile) ? YAML.parse(fs.readFileSync(pipelineFile, 'utf8')).env.UNITY_LICENSE : '';
}

View File

@@ -1,11 +1,10 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest';
import * as core from '@actions/core';
import Input from './input';
import Platform from './platform';
afterEach(() => {
vi.restoreAllMocks();
jest.restoreAllMocks();
});
describe('Input', () => {
@@ -16,7 +15,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = '2020.4.99f9';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.unityVersion).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -28,7 +27,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = '2020.4.99f9';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.customImage).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -41,7 +40,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = 'Android';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.targetPlatform).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -54,7 +53,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = 'customProjectPath';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.projectPath).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -67,7 +66,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = 'path/to/build_profile.asset';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.buildProfile).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -80,14 +79,14 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = 'Build';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.buildName).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
it('takes special characters as input', () => {
const mockValue = '1ßúëld2';
vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.buildName).toStrictEqual(mockValue);
});
});
@@ -99,7 +98,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = 'customBuildsPath';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.buildsPath).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -112,7 +111,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = 'Namespace.ClassName.Method';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.buildMethod).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -124,13 +123,13 @@ describe('Input', () => {
});
it('returns true when string true is passed', () => {
const spy = vi.spyOn(core, 'getInput').mockReturnValue('true');
const spy = jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.manualExit).toStrictEqual(true);
expect(spy).toHaveBeenCalledTimes(1);
});
it('returns false when string false is passed', () => {
const spy = vi.spyOn(core, 'getInput').mockReturnValue('false');
const spy = jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.manualExit).toStrictEqual(false);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -142,13 +141,13 @@ describe('Input', () => {
});
it('returns true when string true is passed', () => {
const spy = vi.spyOn(core, 'getInput').mockReturnValue('true');
const spy = jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.enableGpu).toStrictEqual(true);
expect(spy).toHaveBeenCalledTimes(1);
});
it('returns false when string false is passed', () => {
const spy = vi.spyOn(core, 'getInput').mockReturnValue('false');
const spy = jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.enableGpu).toStrictEqual(false);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -161,7 +160,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = 'Anything';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.versioningStrategy).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -174,7 +173,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = '1.33.7';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.specifiedVersion).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -187,7 +186,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = '42';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.androidVersionCode).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -204,7 +203,7 @@ describe('Input', () => {
${'androidAppBundle'} | ${'androidAppBundle'}
${'androidStudioProject'} | ${'androidStudioProject'}
`('returns $expected when $input is passed', ({ input, expected }) => {
const spy = vi.spyOn(core, 'getInput').mockReturnValue(input);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(input);
expect(Input.androidExportType).toStrictEqual(expected);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -221,7 +220,7 @@ describe('Input', () => {
${'public'} | ${'public'}
${'debugging'} | ${'debugging'}
`('returns $expected when $input is passed', ({ input, expected }) => {
const spy = vi.spyOn(core, 'getInput').mockReturnValue(input);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(input);
expect(Input.androidExportType).toStrictEqual(expected);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -234,7 +233,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = 'keystore.keystore';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.androidKeystoreName).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -247,7 +246,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = 'secret';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.androidKeystoreBase64).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -260,7 +259,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = 'secret';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.androidKeystorePass).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -273,7 +272,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = 'secret';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.androidKeyaliasName).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -286,7 +285,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = 'secret';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.androidKeyaliasPass).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -299,7 +298,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = 'secret';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.androidTargetSdkVersion).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -311,13 +310,13 @@ describe('Input', () => {
});
it('returns true when string true is passed', () => {
const spy = vi.spyOn(core, 'getInput').mockReturnValue('true');
const spy = jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.allowDirtyBuild).toStrictEqual(true);
expect(spy).toHaveBeenCalledTimes(1);
});
it('returns false when string false is passed', () => {
const spy = vi.spyOn(core, 'getInput').mockReturnValue('false');
const spy = jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.allowDirtyBuild).toStrictEqual(false);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -330,7 +329,7 @@ describe('Input', () => {
it('takes input from the users workflow', () => {
const mockValue = '-imAFlag';
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
const spy = jest.spyOn(core, 'getInput').mockReturnValue(mockValue);
expect(Input.customParameters).toStrictEqual(mockValue);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -342,33 +341,15 @@ describe('Input', () => {
});
it('returns true when string true is passed', () => {
const spy = vi.spyOn(core, 'getInput').mockReturnValue('true');
const spy = jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.useHostNetwork).toStrictEqual(true);
expect(spy).toHaveBeenCalledTimes(1);
});
it('returns false when string false is passed', () => {
const spy = vi.spyOn(core, 'getInput').mockReturnValue('false');
const spy = jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.useHostNetwork).toStrictEqual(false);
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe('linux64RemoveExecutableExtension', () => {
it('returns the default value', () => {
expect(Input.linux64RemoveExecutableExtension).toStrictEqual(false);
});
it('returns true when string true is passed', () => {
const spy = vi.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.linux64RemoveExecutableExtension).toStrictEqual(true);
expect(spy).toHaveBeenCalledTimes(1);
});
it('returns false when string false is passed', () => {
const spy = vi.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.linux64RemoveExecutableExtension).toStrictEqual(false);
expect(spy).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { PluginOptions } from './plugin-options';
import { Cli } from './cli/cli';
import Platform from './platform';
import GitHub from './github';
import os from 'node:os';
@@ -28,8 +28,8 @@ class Input {
const alternativeQuery = Input.ToEnvVarFormat(query);
// Query input sources
if (PluginOptions.query(query, alternativeQuery)) {
return PluginOptions.query(query, alternativeQuery);
if (Cli.query(query, alternativeQuery)) {
return Cli.query(query, alternativeQuery);
}
if (process.env[query] !== undefined) {
@@ -47,10 +47,7 @@ class Input {
static get branch(): string {
if (Input.getInput(`GITHUB_REF`)) {
return Input.getInput(`GITHUB_REF`)!
.replace('refs/', '')
.replace(`head/`, '')
.replace(`heads/`, '');
return Input.getInput(`GITHUB_REF`)!.replace('refs/', '').replace(`head/`, '').replace(`heads/`, '');
} else if (Input.getInput('branch')) {
return Input.getInput('branch')!;
} else {
@@ -122,10 +119,6 @@ class Input {
return Input.getInput('unityLicensingServer') ?? '';
}
static get unityLicensingToolset(): string {
return Input.getInput('unityLicensingToolset') ?? '';
}
static get buildMethod(): string {
return Input.getInput('buildMethod') ?? ''; // Processed in docker file
}
@@ -267,8 +260,7 @@ class Input {
}
return (
Input.getInput('dockerMemoryLimit') ??
`${Math.floor((os.totalmem() / bytesInMegabyte) * memoryMultiplier)}m`
Input.getInput('dockerMemoryLimit') ?? `${Math.floor((os.totalmem() / bytesInMegabyte) * memoryMultiplier)}m`
);
}
@@ -288,12 +280,6 @@ class Input {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
static get linux64RemoveExecutableExtension(): boolean {
const input = Input.getInput('linux64RemoveExecutableExtension') ?? 'false';
return input === 'true';
}
public static ToEnvVarFormat(input: string) {
if (input.toUpperCase() === input) {
return input;

View File

@@ -1,17 +1,129 @@
import { describe, expect, it } from 'vitest';
/**
* Compatibility tests for the legacy orchestrator-plugin module name.
* Tests for the orchestrator plugin loader (orchestrator-plugin.ts).
*
* CI targets this file pattern directly, and consumers may still import this
* module while migrating to the generic plugin API.
* The plugin loader dynamically imports @game-ci/orchestrator and calls
* createPlugin(). Two scenarios:
*
* 1. Package NOT installed — loadOrchestratorPlugin() returns undefined.
* 2. Package IS installed — returns the plugin from createPlugin().
*/
describe('orchestrator-plugin compatibility exports', () => {
it('keeps loadOrchestratorPlugin as an alias for loadPlugin', async () => {
const plugin = await import('./plugin');
const compatibility = await import('./orchestrator-plugin');
const mockWarning = jest.fn();
const mockInfo = jest.fn();
jest.mock('@actions/core', () => ({
warning: mockWarning,
info: mockInfo,
}));
expect(compatibility.loadOrchestratorPlugin).toBe(plugin.loadPlugin);
beforeEach(() => {
jest.resetModules();
mockWarning.mockClear();
mockInfo.mockClear();
});
// ---------------------------------------------------------------------------
// Part 1: Package NOT installed
// ---------------------------------------------------------------------------
describe('orchestrator-plugin (package not installed)', () => {
it('loadOrchestratorPlugin() returns undefined', async () => {
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
const result = await loadOrchestratorPlugin();
expect(result).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Part 2: Package IS installed (mocked)
// ---------------------------------------------------------------------------
describe('orchestrator-plugin (package installed)', () => {
const fakePlugin = {
initialize: jest.fn(),
canHandleBuild: jest.fn().mockReturnValue(false),
handleBuild: jest.fn().mockResolvedValue({ exitCode: 0 }),
beforeLocalBuild: jest.fn(),
afterLocalBuild: jest.fn(),
handlePostBuild: jest.fn(),
};
const mockCreatePlugin = jest.fn().mockReturnValue(fakePlugin);
function installOrchestratorMock(overrides: Record<string, unknown> = {}) {
jest.doMock(
'@game-ci/orchestrator',
() => ({
createPlugin: mockCreatePlugin,
...overrides,
}),
{ virtual: true },
);
}
beforeEach(() => {
mockCreatePlugin.mockClear();
fakePlugin.initialize.mockClear();
fakePlugin.canHandleBuild.mockClear();
fakePlugin.handleBuild.mockClear();
fakePlugin.beforeLocalBuild.mockClear();
fakePlugin.afterLocalBuild.mockClear();
fakePlugin.handlePostBuild.mockClear();
});
it('returns the plugin from createPlugin()', async () => {
installOrchestratorMock();
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
const plugin = await loadOrchestratorPlugin();
expect(plugin).toBeDefined();
expect(mockCreatePlugin).toHaveBeenCalledTimes(1);
expect(plugin).toBe(fakePlugin);
});
it('returns the plugin with all lifecycle methods', async () => {
installOrchestratorMock();
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
const plugin = await loadOrchestratorPlugin();
expect(typeof plugin!.initialize).toBe('function');
expect(typeof plugin!.canHandleBuild).toBe('function');
expect(typeof plugin!.handleBuild).toBe('function');
expect(typeof plugin!.beforeLocalBuild).toBe('function');
expect(typeof plugin!.afterLocalBuild).toBe('function');
expect(typeof plugin!.handlePostBuild).toBe('function');
});
it('returns undefined and warns when createPlugin is not a function', async () => {
installOrchestratorMock({ createPlugin: undefined });
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
const plugin = await loadOrchestratorPlugin();
expect(plugin).toBeUndefined();
expect(mockWarning).toHaveBeenCalledWith(expect.stringContaining('does not export createPlugin'));
});
// -----------------------------------------------------------------------
// Error handling
// -----------------------------------------------------------------------
describe('error handling', () => {
it('propagates non-MODULE_NOT_FOUND errors', async () => {
const importError = new Error('Syntax error in module');
jest.doMock(
'@game-ci/orchestrator',
() => {
throw importError;
},
{ virtual: true },
);
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
await expect(loadOrchestratorPlugin()).rejects.toThrow('Syntax error in module');
});
});
});

View File

@@ -1,2 +1,73 @@
export { loadPlugin as loadOrchestratorPlugin } from './plugin';
export type { Plugin as OrchestratorPlugin } from './plugin';
import * as core from '@actions/core';
/**
* Lifecycle interface for the orchestrator plugin.
*
* The orchestrator reads its own configuration from environment variables
* and GitHub Actions inputs. Unity-builder only calls these lifecycle hooks
* at the appropriate times — it never needs to know individual plugin params.
*/
export interface OrchestratorPlugin {
// eslint-disable-next-line no-unused-vars
initialize(coreParameters: Record<string, any>, workspace: string): Promise<void>;
/** Whether the plugin wants to handle the entire build (remote, hot runner, test workflow). */
canHandleBuild(): boolean;
/**
* Execute the build when canHandleBuild() returns true.
* If the plugin needs to fall back to a local build (e.g. hot runner failure),
* it returns { exitCode: -1, fallbackToLocal: true }.
*/
// eslint-disable-next-line no-unused-vars
handleBuild(baseImage: string): Promise<{ exitCode: number; fallbackToLocal?: boolean }>;
/** Pre-build hook for local builds (cache restore, git hooks, sync, etc.). */
// eslint-disable-next-line no-unused-vars
beforeLocalBuild(workspace: string): Promise<void>;
/** Post-build hook for local builds (cache save, workspace save, etc.). */
// eslint-disable-next-line no-unused-vars
afterLocalBuild(workspace: string, exitCode: number): Promise<void>;
/** Post-build hook for all build types (archiving, artifacts, etc.). */
// eslint-disable-next-line no-unused-vars
handlePostBuild(exitCode: number): Promise<void>;
}
/**
* Attempt to load the orchestrator plugin.
* Returns undefined if @game-ci/orchestrator is not installed.
*/
export async function loadOrchestratorPlugin(): Promise<OrchestratorPlugin | undefined> {
try {
// eslint-disable-next-line import/no-unresolved
const orchestratorModule = await import('@game-ci/orchestrator');
if (typeof orchestratorModule.createPlugin !== 'function') {
core.warning(
'Orchestrator package found but does not export createPlugin(). ' +
'Update @game-ci/orchestrator to the latest version.',
);
return;
}
return orchestratorModule.createPlugin();
} catch (error) {
if (!isModuleNotFoundError(error)) {
throw error;
}
}
}
function isModuleNotFoundError(error: unknown): boolean {
if (error && typeof error === 'object' && 'code' in error) {
const code = (error as { code: string }).code;
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') {
return true;
}
}
return typeof (error as Error)?.message === 'string' && /cannot find module/i.test((error as Error).message);
}

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
import Output from './output';
describe('Output', () => {

View File

@@ -32,13 +32,6 @@ class PlatformSetup {
let servicesConfig = fs.readFileSync(servicesConfigPathTemplate).toString();
servicesConfig = servicesConfig.replace('%URL%', buildParameters.unityLicensingServer);
if (buildParameters.unityLicensingToolset) {
const parsed = JSON.parse(servicesConfig);
parsed.toolset = buildParameters.unityLicensingToolset;
servicesConfig = JSON.stringify(parsed, undefined, 2);
}
fs.writeFileSync(servicesConfigPath, servicesConfig);
SetupAndroid.setup(buildParameters);

View File

@@ -4,30 +4,17 @@ import { BuildParameters } from '..';
class SetupAndroid {
public static async setup(buildParameters: BuildParameters) {
const { targetPlatform, androidKeystoreBase64, androidKeystoreName, projectPath } =
buildParameters;
const { targetPlatform, androidKeystoreBase64, androidKeystoreName, projectPath } = buildParameters;
if (
targetPlatform === 'Android' &&
androidKeystoreBase64 !== '' &&
androidKeystoreName !== ''
) {
if (targetPlatform === 'Android' && androidKeystoreBase64 !== '' && androidKeystoreName !== '') {
SetupAndroid.setupAndroidRun(androidKeystoreBase64, androidKeystoreName, projectPath);
}
}
private static setupAndroidRun(
androidKeystoreBase64: string,
androidKeystoreName: string,
projectPath: string,
) {
private static setupAndroidRun(androidKeystoreBase64: string, androidKeystoreName: string, projectPath: string) {
const decodedKeystore = Buffer.from(androidKeystoreBase64, 'base64').toString('binary');
const githubWorkspace = process.env.GITHUB_WORKSPACE || '';
fs.writeFileSync(
path.join(githubWorkspace, projectPath, androidKeystoreName),
decodedKeystore,
'binary',
);
fs.writeFileSync(path.join(githubWorkspace, projectPath, androidKeystoreName), decodedKeystore, 'binary');
}
}

View File

@@ -42,8 +42,7 @@ class SetupMac {
}
}
const commandSuffix =
buildParameters.unityHubVersionOnMac !== '' ? `@${buildParameters.unityHubVersionOnMac}` : '';
const commandSuffix = buildParameters.unityHubVersionOnMac !== '' ? `@${buildParameters.unityHubVersionOnMac}` : '';
const command = `brew install unity-hub${commandSuffix}`;
// Ignoring return code because the log seems to overflow the internal buffer which triggers
@@ -53,9 +52,7 @@ class SetupMac {
ignoreReturnCode: true,
});
if (errorCode) {
throw new Error(
`There was an error installing the Unity Editor. See logs above for details.`,
);
throw new Error(`There was an error installing the Unity Editor. See logs above for details.`);
}
if (buildParameters.cacheUnityInstallationOnMac) {
@@ -138,9 +135,7 @@ class SetupMac {
}
const unityChangeset = await getUnityChangeset(buildParameters.editorVersion);
const moduleArguments = SetupMac.getModuleParametersForTargetPlatform(
buildParameters.targetPlatform,
);
const moduleArguments = SetupMac.getModuleParametersForTargetPlatform(buildParameters.targetPlatform);
const architectureArguments = SetupMac.getArchitectureParameters();
const execArguments: string[] = [
@@ -161,9 +156,7 @@ class SetupMac {
ignoreReturnCode: true,
});
if (errorCode) {
throw new Error(
`There was an error installing the Unity Editor. See logs above for details.`,
);
throw new Error(`There was an error installing the Unity Editor. See logs above for details.`);
}
if (buildParameters.cacheUnityInstallationOnMac) {
@@ -171,10 +164,7 @@ class SetupMac {
}
}
private static async setEnvironmentVariables(
buildParameters: BuildParameters,
actionFolder: string,
) {
private static async setEnvironmentVariables(buildParameters: BuildParameters, actionFolder: string) {
// Need to set environment variables from here because we execute
// the scripts on the host for mac
process.env.ACTION_FOLDER = actionFolder;

View File

@@ -42,9 +42,7 @@ class ValidateWindows {
private static checkForVisualStudio() {
// Note: When upgrading to Server 2022, we will need to move to just "program files" since VS will be 64-bit
const visualStudioInstallPathExists = fs.existsSync(
'C:/Program Files (x86)/Microsoft Visual Studio',
);
const visualStudioInstallPathExists = fs.existsSync('C:/Program Files (x86)/Microsoft Visual Studio');
const visualStudioDataPathExists = fs.existsSync('C:/ProgramData/Microsoft/VisualStudio');
if (!visualStudioInstallPathExists || !visualStudioDataPathExists) {

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
import Platform from './platform';
describe('Platform', () => {

View File

@@ -1,32 +0,0 @@
/**
* Shared options bridge between unity-builder and plugins (e.g. @game-ci/orchestrator).
*
* Plugins set PluginOptions.options to pass configuration into BuildParameters
* and Input. When options are set, isPluginMode is true and query() reads
* from the options map instead of @actions/core.getInput().
*/
export class PluginOptions {
public static options: Record<string, any> | undefined;
static get isPluginMode() {
return Boolean(PluginOptions.options?.mode);
}
public static query(key: string, alternativeKey: string) {
if (PluginOptions.options && PluginOptions.options[key] !== undefined) {
return PluginOptions.options[key];
}
if (
PluginOptions.options &&
alternativeKey &&
PluginOptions.options[alternativeKey] !== undefined
) {
return PluginOptions.options[alternativeKey];
}
return;
}
}
// Backwards-compatible alias — the orchestrator still imports { Cli }
export { PluginOptions as Cli };

View File

@@ -1,116 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Tests for the generic plugin loader (plugin.ts).
*
* The default plugin implementation is currently @game-ci/orchestrator, but
* unity-builder depends on the generic Plugin lifecycle rather than an
* orchestrator-specific type.
*/
const mockWarning = vi.fn();
const mockInfo = vi.fn();
vi.mock('@actions/core', () => ({
warning: mockWarning,
info: mockInfo,
}));
beforeEach(() => {
vi.resetModules();
mockWarning.mockClear();
mockInfo.mockClear();
});
describe('plugin (default package not installed)', () => {
it('loadPlugin() returns undefined', async () => {
const { loadPlugin } = await import('./plugin');
const result = await loadPlugin();
expect(result).toBeUndefined();
});
});
describe('plugin (default package installed)', () => {
const fakePlugin = {
initialize: vi.fn(),
canHandleBuild: vi.fn().mockReturnValue(false),
handleBuild: vi.fn().mockResolvedValue({ exitCode: 0 }),
beforeLocalBuild: vi.fn(),
afterLocalBuild: vi.fn(),
handlePostBuild: vi.fn(),
};
const mockCreatePlugin = vi.fn().mockReturnValue(fakePlugin);
function installDefaultPluginMock(overrides: Record<string, unknown> = {}) {
// The `@game-ci/orchestrator` module is intentionally optional and may not
// be installed. `vi.doMock` lets the dynamic import in the loader resolve
// through this factory before vite tries to load a real package.
vi.doMock('@game-ci/orchestrator', () => ({
createPlugin: mockCreatePlugin,
...overrides,
}));
}
beforeEach(() => {
mockCreatePlugin.mockClear();
fakePlugin.initialize.mockClear();
fakePlugin.canHandleBuild.mockClear();
fakePlugin.handleBuild.mockClear();
fakePlugin.beforeLocalBuild.mockClear();
fakePlugin.afterLocalBuild.mockClear();
fakePlugin.handlePostBuild.mockClear();
});
it('returns the plugin from createPlugin()', async () => {
installDefaultPluginMock();
const { loadPlugin } = await import('./plugin');
const plugin = await loadPlugin();
expect(plugin).toBeDefined();
expect(mockCreatePlugin).toHaveBeenCalledTimes(1);
expect(plugin).toBe(fakePlugin);
});
it('returns a plugin with all lifecycle methods', async () => {
installDefaultPluginMock();
const { loadPlugin } = await import('./plugin');
const plugin = await loadPlugin();
expect(typeof plugin!.initialize).toBe('function');
expect(typeof plugin!.canHandleBuild).toBe('function');
expect(typeof plugin!.handleBuild).toBe('function');
expect(typeof plugin!.beforeLocalBuild).toBe('function');
expect(typeof plugin!.afterLocalBuild).toBe('function');
expect(typeof plugin!.handlePostBuild).toBe('function');
});
it('returns undefined and warns when createPlugin is not a function', async () => {
installDefaultPluginMock({ createPlugin: undefined });
const { loadPlugin } = await import('./plugin');
const plugin = await loadPlugin();
expect(plugin).toBeUndefined();
expect(mockWarning).toHaveBeenCalledWith(
expect.stringContaining('does not export createPlugin'),
);
});
it('propagates non-MODULE_NOT_FOUND errors', async () => {
// Throw lazily from `createPlugin` rather than from the mock factory
// itself: vitest 4 wraps factory-time errors with its own message, which
// masks the inner error at the assertion site.
installDefaultPluginMock({
createPlugin: () => {
throw new Error('Syntax error in module');
},
});
const { loadPlugin } = await import('./plugin');
await expect(loadPlugin()).rejects.toThrow('Syntax error in module');
});
});

View File

@@ -1,76 +0,0 @@
import * as core from '@actions/core';
const DEFAULT_PLUGIN_MODULE = '@game-ci/orchestrator';
/**
* Generic lifecycle contract for optional unity-builder plugins.
*
* Plugins read their own configuration from environment variables and GitHub
* Actions inputs. Unity-builder only calls lifecycle hooks at the points where
* an external implementation can extend or replace the local build flow.
*/
export interface Plugin {
initialize(coreParameters: Record<string, any>, workspace: string): Promise<void>;
/** Whether the plugin wants to handle the entire build. */
canHandleBuild(): boolean;
/**
* Execute the build when canHandleBuild() returns true.
* If the plugin needs to fall back to a local build, it returns
* { exitCode: -1, fallbackToLocal: true }.
*/
handleBuild(baseImage: string): Promise<{ exitCode: number; fallbackToLocal?: boolean }>;
/** Pre-build hook for local builds. */
beforeLocalBuild(workspace: string): Promise<void>;
/** Post-build hook for local builds. */
afterLocalBuild(workspace: string, exitCode: number): Promise<void>;
/** Post-build hook for all build types. */
handlePostBuild(exitCode: number): Promise<void>;
}
/**
* Attempt to load the default optional plugin.
*
* Today the default implementation is @game-ci/orchestrator. The loader is
* intentionally named after the generic plugin contract so additional plugin
* implementations can be added without making orchestrator part of the core
* abstraction.
*/
export async function loadPlugin(moduleName = DEFAULT_PLUGIN_MODULE): Promise<Plugin | undefined> {
try {
const pluginModule = await import(/* webpackIgnore: true */ moduleName);
if (typeof pluginModule.createPlugin !== 'function') {
core.warning(
`Plugin package "${moduleName}" found but does not export createPlugin(). ` +
'Update the plugin package to the latest version.',
);
return;
}
return pluginModule.createPlugin();
} catch (error) {
if (!isModuleNotFoundError(error)) {
throw error;
}
}
}
function isModuleNotFoundError(error: unknown): boolean {
if (error && typeof error === 'object' && 'code' in error) {
const code = (error as { code: string }).code;
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') {
return true;
}
}
return (
typeof (error as Error)?.message === 'string' &&
/cannot find module/i.test((error as Error).message)
);
}

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest';
import Project from './project';
vi.mock('./input');
jest.mock('./input');
describe('Platform', () => {
describe('relativePath', () => {

View File

@@ -1,13 +1,12 @@
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest';
import * as core from '@actions/core';
import System from './system';
vi.spyOn(core, 'debug').mockImplementation(() => {});
vi.spyOn(core, 'info').mockImplementation(() => {});
vi.spyOn(core, 'warning').mockImplementation(() => {});
vi.spyOn(core, 'error').mockImplementation(() => {});
jest.spyOn(core, 'debug').mockImplementation(() => {});
jest.spyOn(core, 'info').mockImplementation(() => {});
jest.spyOn(core, 'warning').mockImplementation(() => {});
jest.spyOn(core, 'error').mockImplementation(() => {});
afterEach(() => vi.clearAllMocks());
afterEach(() => jest.clearAllMocks());
describe('System', () => {
describe('run', () => {

View File

@@ -1,26 +1,21 @@
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest';
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import System from './system';
vi.spyOn(core, 'debug').mockImplementation(() => {});
const info = vi.spyOn(core, 'info').mockImplementation(() => {});
vi.spyOn(core, 'warning').mockImplementation(() => {});
vi.spyOn(core, 'error').mockImplementation(() => {});
const execSpy = vi.spyOn(exec, 'exec').mockImplementation(async () => 0);
jest.spyOn(core, 'debug').mockImplementation(() => {});
const info = jest.spyOn(core, 'info').mockImplementation(() => {});
jest.spyOn(core, 'warning').mockImplementation(() => {});
jest.spyOn(core, 'error').mockImplementation(() => {});
const execSpy = jest.spyOn(exec, 'exec').mockImplementation(async () => 0);
afterEach(() => vi.clearAllMocks());
afterEach(() => jest.clearAllMocks());
describe('System', () => {
describe('run', () => {
describe('units', () => {
it('passes the command to command line', async () => {
await expect(System.run('echo test')).resolves.not.toBeNull();
await expect(execSpy).toHaveBeenLastCalledWith(
'echo test',
expect.anything(),
expect.anything(),
);
await expect(execSpy).toHaveBeenLastCalledWith('echo test', expect.anything(), expect.anything());
});
it('throws on when error code is not 0', async () => {

View File

@@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
import UnityVersioning from './unity-versioning';
describe('Unity Versioning', () => {

View File

@@ -13,9 +13,7 @@ export default class UnityVersioning {
static read(projectPath: string) {
const filePath = path.join(projectPath, 'ProjectSettings', 'ProjectVersion.txt');
if (!fs.existsSync(filePath)) {
throw new Error(
`Project settings file not found at "${filePath}". Have you correctly set the projectPath?`,
);
throw new Error(`Project settings file not found at "${filePath}". Have you correctly set the projectPath?`);
}
return UnityVersioning.parse(fs.readFileSync(filePath, 'utf8'));

View File

@@ -1,4 +1,3 @@
import { describe, it, expect } from 'vitest';
import Unity from './unity';
describe('Unity', () => {

View File

@@ -1,4 +1,3 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest';
import * as core from '@actions/core';
import NotImplementedException from './error/not-implemented-exception';
import System from './system';
@@ -6,7 +5,7 @@ import Versioning from './versioning';
import { validVersionTagInputs, invalidVersionTagInputs } from './__data__/versions';
afterEach(() => {
vi.restoreAllMocks();
jest.restoreAllMocks();
});
describe('Versioning', () => {
@@ -40,9 +39,7 @@ describe('Versioning', () => {
// eslint-disable-next-line unicorn/consistent-function-scoping
const matchInputUsingGrep = async (input: string) => {
const output = await System.run('sh', undefined, {
input: Buffer.from(
`echo '${input}' | grep -E '${Versioning.grepCompatibleInputVersionRegex}'`,
),
input: Buffer.from(`echo '${input}' | grep -E '${Versioning.grepCompatibleInputVersionRegex}'`),
silent: true,
});
@@ -53,39 +50,30 @@ describe('Versioning', () => {
expect(await matchInputUsingGrep(input)).toStrictEqual(input);
});
it.concurrent.each(invalidVersionTagInputs)(
`rejects non-version tag input '%s'`,
async (input) => {
await expect(async () => matchInputUsingGrep(input)).rejects.toThrowError(/^Failed to run/);
},
);
it.concurrent.each(invalidVersionTagInputs)(`rejects non-version tag input '%s'`, async (input) => {
await expect(async () => matchInputUsingGrep(input)).rejects.toThrowError(/^Failed to run/);
});
});
describe('branch', () => {
it('returns headRef when set', () => {
const headReference = vi
.spyOn(Versioning, 'headRef', 'get')
.mockReturnValue('feature-branch-1');
const headReference = jest.spyOn(Versioning, 'headRef', 'get').mockReturnValue('feature-branch-1');
expect(Versioning.branch).toStrictEqual('feature-branch-1');
expect(headReference).toHaveBeenCalledTimes(1);
});
it('returns part of Ref when set', () => {
vi.spyOn(Versioning, 'headRef', 'get').mockImplementation(() => undefined);
const reference = vi
.spyOn(Versioning, 'ref', 'get')
.mockReturnValue('refs/heads/feature-branch-2');
jest.spyOn(Versioning, 'headRef', 'get').mockImplementation();
const reference = jest.spyOn(Versioning, 'ref', 'get').mockReturnValue('refs/heads/feature-branch-2');
expect(Versioning.branch).toStrictEqual('feature-branch-2');
expect(reference).toHaveBeenCalledTimes(1);
});
it('prefers headRef over ref when set', () => {
const headReference = vi
.spyOn(Versioning, 'headRef', 'get')
.mockReturnValue('feature-branch-1');
const reference = vi.spyOn(Versioning, 'ref', 'get').mockReturnValue('refs/heads/feature-2');
const headReference = jest.spyOn(Versioning, 'headRef', 'get').mockReturnValue('feature-branch-1');
const reference = jest.spyOn(Versioning, 'ref', 'get').mockReturnValue('refs/heads/feature-2');
expect(Versioning.branch).toStrictEqual('feature-branch-1');
expect(headReference).toHaveBeenCalledTimes(1);
@@ -93,10 +81,8 @@ describe('Versioning', () => {
});
it('returns undefined when headRef and ref are not set', () => {
const headReference = vi
.spyOn(Versioning, 'headRef', 'get')
.mockImplementation(() => undefined);
const reference = vi.spyOn(Versioning, 'ref', 'get').mockImplementation(() => undefined);
const headReference = jest.spyOn(Versioning, 'headRef', 'get').mockImplementation();
const reference = jest.spyOn(Versioning, 'ref', 'get').mockImplementation();
expect(Versioning.branch).not.toBeDefined();
@@ -120,19 +106,16 @@ describe('Versioning', () => {
describe('logging git diff', () => {
it('calls git diff', async () => {
// allowDirtyBuild: true
vi.spyOn(core, 'getInput').mockReturnValue('true');
vi.spyOn(Versioning, 'isShallow').mockResolvedValue(true);
vi.spyOn(Versioning, 'isDirty').mockResolvedValue(false);
vi.spyOn(Versioning, 'fetch').mockResolvedValue();
vi.spyOn(Versioning, 'hasAnyVersionTags').mockResolvedValue(true);
vi.spyOn(Versioning, 'parseSemanticVersion').mockResolvedValue({
match: '',
tag: 'mocktag',
commits: 'abcdef',
hash: '75822BCAF',
});
const logDiffSpy = vi.spyOn(Versioning, 'logDiff');
const gitSpy = vi.spyOn(System, 'run').mockResolvedValue('');
jest.spyOn(core, 'getInput').mockReturnValue('true');
jest.spyOn(Versioning, 'isShallow').mockResolvedValue(true);
jest.spyOn(Versioning, 'isDirty').mockResolvedValue(false);
jest.spyOn(Versioning, 'fetch').mockImplementation();
jest.spyOn(Versioning, 'hasAnyVersionTags').mockResolvedValue(true);
jest
.spyOn(Versioning, 'parseSemanticVersion')
.mockResolvedValue({ match: '', tag: 'mocktag', commits: 'abcdef', hash: '75822BCAF' });
const logDiffSpy = jest.spyOn(Versioning, 'logDiff');
const gitSpy = jest.spyOn(System, 'run').mockImplementation();
await Versioning.generateSemanticVersion();
@@ -174,16 +157,12 @@ describe('Versioning', () => {
describe('determineBuildVersion', () => {
test.each(['somethingRandom'])('throws for invalid strategy %s', async (strategy) => {
await expect(
Versioning.determineBuildVersion(strategy, ''),
).rejects.toThrowErrorMatchingSnapshot();
await expect(Versioning.determineBuildVersion(strategy, '')).rejects.toThrowErrorMatchingSnapshot();
});
describe('opt out strategy', () => {
it("returns 'none'", async () => {
await expect(
Versioning.determineBuildVersion('None', 'v1.0'),
).resolves.toMatchInlineSnapshot(`"none"`);
await expect(Versioning.determineBuildVersion('None', 'v1.0')).resolves.toMatchInlineSnapshot(`"none"`);
});
});
@@ -191,31 +170,23 @@ describe('Versioning', () => {
test.each(['v0.1', '1', 'CamelCase', 'dashed-version'])(
'returns the inputVersion for %s',
async (inputVersion) => {
await expect(
Versioning.determineBuildVersion('Custom', inputVersion),
).resolves.toStrictEqual(inputVersion);
await expect(Versioning.determineBuildVersion('Custom', inputVersion)).resolves.toStrictEqual(inputVersion);
},
);
});
describe('semantic strategy', () => {
it('refers to generateSemanticVersion', async () => {
const generateSemanticVersion = vi
.spyOn(Versioning, 'generateSemanticVersion')
.mockResolvedValue('1.3.37');
const generateSemanticVersion = jest.spyOn(Versioning, 'generateSemanticVersion').mockResolvedValue('1.3.37');
await expect(Versioning.determineBuildVersion('Semantic', '')).resolves.toStrictEqual(
'1.3.37',
);
await expect(Versioning.determineBuildVersion('Semantic', '')).resolves.toStrictEqual('1.3.37');
expect(generateSemanticVersion).toHaveBeenCalledTimes(1);
});
});
describe('tag strategy', () => {
it('refers to generateTagVersion', async () => {
const generateTagVersion = vi
.spyOn(Versioning, 'generateTagVersion')
.mockResolvedValue('0.1');
const generateTagVersion = jest.spyOn(Versioning, 'generateTagVersion').mockResolvedValue('0.1');
await expect(Versioning.determineBuildVersion('Tag', '')).resolves.toStrictEqual('0.1');
expect(generateTagVersion).toHaveBeenCalledTimes(1);
@@ -226,24 +197,22 @@ describe('Versioning', () => {
it('throws a not implemented exception', async () => {
const strategy = 'Test';
// @ts-ignore
vi.spyOn(Versioning, 'strategies', 'get').mockReturnValue({ [strategy]: strategy });
await expect(Versioning.determineBuildVersion(strategy, '')).rejects.toThrowError(
NotImplementedException,
);
jest.spyOn(Versioning, 'strategies', 'get').mockReturnValue({ [strategy]: strategy });
await expect(Versioning.determineBuildVersion(strategy, '')).rejects.toThrowError(NotImplementedException);
});
});
});
describe('generateTagVersion', () => {
it('removes the v', async () => {
vi.spyOn(Versioning, 'getTag').mockResolvedValue('v1.3.37');
jest.spyOn(Versioning, 'getTag').mockResolvedValue('v1.3.37');
await expect(Versioning.generateTagVersion()).resolves.toStrictEqual('1.3.37');
});
});
describe('parseSemanticVersion', () => {
it('returns the named parts', async () => {
vi.spyOn(Versioning, 'getVersionDescription').mockResolvedValue('v0.1-2-g12345678');
jest.spyOn(Versioning, 'getVersionDescription').mockResolvedValue('v0.1-2-g12345678');
await expect(Versioning.parseSemanticVersion()).resolves.toMatchObject({
tag: '0.1',
@@ -253,7 +222,7 @@ describe('Versioning', () => {
});
it('throws when no match could be made', async () => {
vi.spyOn(Versioning, 'getVersionDescription').mockResolvedValue('no-match-can-be-made');
jest.spyOn(Versioning, 'getVersionDescription').mockResolvedValue('no-match-can-be-made');
await expect(Versioning.parseSemanticVersion()).toMatchObject({});
});
@@ -262,7 +231,7 @@ describe('Versioning', () => {
describe('getVersionDescription', () => {
it('returns the commands output', async () => {
const runOutput = 'someValue';
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
jest.spyOn(System, 'run').mockResolvedValue(runOutput);
await expect(Versioning.getVersionDescription()).resolves.toStrictEqual(runOutput);
});
});
@@ -270,27 +239,27 @@ describe('Versioning', () => {
describe('isShallow', () => {
it('returns true when the repo is shallow', async () => {
const runOutput = 'true\n';
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
jest.spyOn(System, 'run').mockResolvedValue(runOutput);
await expect(Versioning.isShallow()).resolves.toStrictEqual(true);
});
it('returns false when the repo is not shallow', async () => {
const runOutput = 'false\n';
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
jest.spyOn(System, 'run').mockResolvedValue(runOutput);
await expect(Versioning.isShallow()).resolves.toStrictEqual(false);
});
});
describe('fetch', () => {
it('awaits the command', async () => {
vi.spyOn(core, 'warning').mockImplementation(() => {});
vi.spyOn(System, 'run').mockResolvedValue('');
jest.spyOn(core, 'warning').mockImplementation(() => {});
jest.spyOn(System, 'run').mockImplementation();
await expect(Versioning.fetch()).resolves.not.toThrow();
});
it('falls back to the second strategy when the first fails', async () => {
vi.spyOn(core, 'warning').mockImplementation(() => {});
const gitFetch = vi.spyOn(System, 'run').mockResolvedValue('');
jest.spyOn(core, 'warning').mockImplementation(() => {});
const gitFetch = jest.spyOn(System, 'run').mockImplementation();
await expect(Versioning.fetch()).resolves.not.toThrow();
expect(gitFetch).toHaveBeenCalledTimes(1);
@@ -299,12 +268,12 @@ describe('Versioning', () => {
describe('generateSemanticVersion', () => {
it('returns a proper version from description', async () => {
vi.spyOn(System, 'run').mockResolvedValue('');
vi.spyOn(core, 'info').mockImplementation(() => {});
vi.spyOn(Versioning, 'isDirty').mockResolvedValue(false);
vi.spyOn(Versioning, 'hasAnyVersionTags').mockResolvedValue(true);
vi.spyOn(Versioning, 'getTotalNumberOfCommits').mockResolvedValue(2);
vi.spyOn(Versioning, 'parseSemanticVersion').mockResolvedValue({
jest.spyOn(System, 'run').mockImplementation();
jest.spyOn(core, 'info').mockImplementation(() => {});
jest.spyOn(Versioning, 'isDirty').mockResolvedValue(false);
jest.spyOn(Versioning, 'hasAnyVersionTags').mockResolvedValue(true);
jest.spyOn(Versioning, 'getTotalNumberOfCommits').mockResolvedValue(2);
jest.spyOn(Versioning, 'parseSemanticVersion').mockResolvedValue({
match: '0.1-2-g1b345678',
tag: '0.1',
commits: '2',
@@ -315,19 +284,19 @@ describe('Versioning', () => {
});
it('throws when dirty', async () => {
vi.spyOn(System, 'run').mockResolvedValue('');
vi.spyOn(core, 'info').mockImplementation(() => {});
vi.spyOn(Versioning, 'isDirty').mockResolvedValue(true);
jest.spyOn(System, 'run').mockImplementation();
jest.spyOn(core, 'info').mockImplementation(() => {});
jest.spyOn(Versioning, 'isDirty').mockResolvedValue(true);
await expect(Versioning.generateSemanticVersion()).rejects.toThrowError();
});
it('falls back to commits only, when no tags are present', async () => {
const commits = Math.round(Math.random() * 10);
vi.spyOn(System, 'run').mockResolvedValue('');
vi.spyOn(core, 'info').mockImplementation(() => {});
vi.spyOn(Versioning, 'isDirty').mockResolvedValue(false);
vi.spyOn(Versioning, 'hasAnyVersionTags').mockResolvedValue(false);
vi.spyOn(Versioning, 'getTotalNumberOfCommits').mockResolvedValue(commits);
jest.spyOn(System, 'run').mockImplementation();
jest.spyOn(core, 'info').mockImplementation(() => {});
jest.spyOn(Versioning, 'isDirty').mockResolvedValue(false);
jest.spyOn(Versioning, 'hasAnyVersionTags').mockResolvedValue(false);
jest.spyOn(Versioning, 'getTotalNumberOfCommits').mockResolvedValue(commits);
await expect(Versioning.generateSemanticVersion()).resolves.toStrictEqual(`0.0.${commits}`);
});
@@ -336,13 +305,13 @@ describe('Versioning', () => {
describe('isDirty', () => {
it('returns true when there are files listed', async () => {
const runOutput = 'file.ext\nfile2.ext';
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
jest.spyOn(System, 'run').mockResolvedValue(runOutput);
await expect(Versioning.isDirty()).resolves.toStrictEqual(true);
});
it('returns false when there is no output', async () => {
const runOutput = '';
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
jest.spyOn(System, 'run').mockResolvedValue(runOutput);
await expect(Versioning.isDirty()).resolves.toStrictEqual(false);
});
});
@@ -350,7 +319,7 @@ describe('Versioning', () => {
describe('getTag', () => {
it('returns the commands output', async () => {
const runOutput = 'v1.0';
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
jest.spyOn(System, 'run').mockResolvedValue(runOutput);
await expect(Versioning.getTag()).resolves.toStrictEqual(runOutput);
});
});
@@ -358,20 +327,20 @@ describe('Versioning', () => {
describe('hasAnyVersionTags', () => {
it('returns false when the command returns 0', async () => {
const runOutput = '0';
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
jest.spyOn(System, 'run').mockResolvedValue(runOutput);
await expect(Versioning.hasAnyVersionTags()).resolves.toStrictEqual(false);
});
it('returns true when the command returns >= 0', async () => {
const runOutput = '9';
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
jest.spyOn(System, 'run').mockResolvedValue(runOutput);
await expect(Versioning.hasAnyVersionTags()).resolves.toStrictEqual(true);
});
});
describe('getTotalNumberOfCommits', () => {
it('returns a number from the command', async () => {
vi.spyOn(System, 'run').mockResolvedValue('9');
jest.spyOn(System, 'run').mockResolvedValue('9');
await expect(Versioning.getTotalNumberOfCommits()).resolves.toStrictEqual(9);
});
});

View File

@@ -71,9 +71,7 @@ export default class Versioning {
static async determineBuildVersion(strategy: string, inputVersion: string): Promise<string> {
// Validate input
if (!Object.hasOwnProperty.call(this.strategies, strategy)) {
throw new ValidationError(
`Versioning strategy should be one of ${Object.values(this.strategies).join(', ')}.`,
);
throw new ValidationError(`Versioning strategy should be one of ${Object.values(this.strategies).join(', ')}.`);
}
switch (strategy) {
@@ -124,9 +122,7 @@ export default class Versioning {
// Ensure 3 digits (commits should always be patch level)
const [major, minor, patch] = `${tag}.${commits}`.split('.');
const threeDigitVersion = /^\d+$/.test(patch)
? `${major}.${minor}.${patch}`
: `${major}.0.${minor}`;
const threeDigitVersion = /^\d+$/.test(patch) ? `${major}.${minor}.${patch}` : `${major}.0.${minor}`;
core.info(`Found semantic version ${threeDigitVersion} for ${this.branch}@${hash}`);
@@ -172,9 +168,7 @@ export default class Versioning {
}
}
core.warning(
`Failed to parse git describe output or version can not be determined through: "${description}".`,
);
core.warning(`Failed to parse git describe output or version can not be determined through: "${description}".`);
return false;
}
@@ -213,9 +207,7 @@ export default class Versioning {
* identifies the current commit.
*/
static async getVersionDescription() {
const versionTags = (
await this.git(['tag', '--list', '--merged', 'HEAD', '--sort=-creatordate'])
)
const versionTags = (await this.git(['tag', '--list', '--merged', 'HEAD', '--sort=-creatordate']))
.split('\n')
.filter((tag) => new RegExp(this.grepCompatibleInputVersionRegex).test(tag));
@@ -226,9 +218,7 @@ export default class Versioning {
}
const latestVersionTag = versionTags[0];
const commitsCount = (
await this.git(['rev-list', `${latestVersionTag}..HEAD`, '--count'])
).trim();
const commitsCount = (await this.git(['rev-list', `${latestVersionTag}..HEAD`, '--count'])).trim();
const commitHash = (await this.git(['rev-parse', '--short', 'HEAD'])).trim();
return `${latestVersionTag}-${commitsCount}-g${commitHash}`;
@@ -263,9 +253,7 @@ export default class Versioning {
*/
static async hasAnyVersionTags() {
const numberOfTagsAsString = await System.run('sh', undefined, {
input: Buffer.from(
`git tag --list --merged HEAD | grep -E '${this.grepCompatibleInputVersionRegex}' | wc -l`,
),
input: Buffer.from(`git tag --list --merged HEAD | grep -E '${this.grepCompatibleInputVersionRegex}' | wc -l`),
cwd: Input.projectPath,
silent: false,
});

View File

@@ -1,30 +0,0 @@
import { afterEach, beforeEach } from 'vitest';
// Fail tests when console.error / console.warn etc are called from
// production code under test. Mirrors the jest-fail-on-console behaviour
// the previous jest setup enforced. Tests can opt-out by replacing the
// method with vi.spyOn(console, 'error') for the duration of that test.
const original = {
log: console.log,
warn: console.warn,
error: console.error,
assert: console.assert,
};
const fail = (level: 'log' | 'warn' | 'error' | 'assert', args: unknown[]) => {
throw new Error(
`console.${level} was called with: ${args.map(String).join(' ')}\n` +
`Tests must use vi.spyOn(console, '${level}') if console output is expected.`,
);
};
beforeEach(() => {
console.log = (...args: unknown[]) => fail('log', args);
console.warn = (...args: unknown[]) => fail('warn', args);
console.error = (...args: unknown[]) => fail('error', args);
console.assert = ((...args: unknown[]) => fail('assert', args)) as typeof console.assert;
});
afterEach(() => {
Object.assign(console, original);
});

View File

@@ -1,12 +1,12 @@
/**
* Type declarations for @game-ci/orchestrator.
*
* This optional dependency is one implementation of unity-builder's generic
* plugin lifecycle. When installed, the plugin loader in plugin.ts
* This optional dependency provides remote build orchestration and plugin
* services. When installed, the plugin loader in orchestrator-plugin.ts
* dynamically imports it.
*/
declare module '@game-ci/orchestrator' {
interface Plugin {
interface OrchestratorPlugin {
initialize(coreParams: Record<string, any>, workspace: string): Promise<void>;
canHandleBuild(): boolean;
handleBuild(baseImage: string): Promise<{ exitCode: number; fallbackToLocal?: boolean }>;
@@ -16,11 +16,11 @@ declare module '@game-ci/orchestrator' {
}
/**
* Create a unity-builder plugin instance.
* The plugin reads its own configuration from environment variables and
* GitHub Actions inputs; unity-builder does not need to proxy them.
* Create an orchestrator plugin instance.
* The plugin reads its own configuration from environment variables
* and GitHub Actions inputs unity-builder does not need to proxy them.
*/
export function createPlugin(): Plugin;
export function createPlugin(): OrchestratorPlugin;
// Legacy export — kept for backward compatibility with CLI and direct consumers
export const Orchestrator: {

View File

@@ -1,19 +1,14 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./lib",
"rootDir": "./src",
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"]
"target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
"outDir": "./lib" /* Redirect output structure to the directory. */,
"rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
"strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["node_modules", "dist", "lib"]
"exclude": ["node_modules", "dist"]
}

View File

@@ -13,3 +13,4 @@ declare module 'shell-quote' {
*/
export function parse(cmd: string): string[];
}

View File

@@ -1,16 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.ts'],
coverage: {
provider: 'istanbul',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/index.ts'],
},
},
});

10945
yarn.lock

File diff suppressed because it is too large Load Diff