Compare commits

..

1 Commits

Author SHA1 Message Date
David Finol
420863deca Fix Android cmdline-tools path 2023-09-25 21:20:29 -05:00
174 changed files with 73630 additions and 134935 deletions

View File

@@ -1,11 +1,22 @@
{
"plugins": ["jest", "@typescript-eslint", "prettier", "unicorn"],
"extends": ["plugin:unicorn/recommended", "plugin:github/recommended", "plugin:prettier/recommended"],
"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"],
"extraFileExtensions": [
".mjs"
],
"ecmaFeatures": {
"impliedStrict": true
},
@@ -14,8 +25,7 @@
"env": {
"node": true,
"es6": true,
"jest/globals": true,
"es2020": true
"jest/globals": true
},
"rules": {
// Error out for code formatting errors
@@ -23,7 +33,10 @@
// Namespaces or sometimes needed
"import/no-namespace": "off",
// Properly format comments
"spaced-comment": ["error", "always"],
"spaced-comment": [
"error",
"always"
],
"lines-around-comment": [
"error",
{
@@ -58,7 +71,12 @@
// Enforce camelCase
"camelcase": "error",
// Allow forOfStatements
"no-restricted-syntax": ["error", "ForInStatement", "LabeledStatement", "WithStatement"],
"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
@@ -78,13 +96,5 @@
"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

@@ -2,28 +2,13 @@
- ...
#### Related Issues
- ...
#### Related PRs
- ...
#### Successful Workflow Run Link
PRs don't have access to secrets so you will need to provide a link to a successful run of the workflows from your own
repo.
- ...
#### Checklist
<!-- please check all items and add your own -->
- [x] Read the contribution [guide](https://github.com/game-ci/unity-builder/blob/main/CONTRIBUTING.md) and accept the
[code](https://github.com/game-ci/unity-builder/blob/main/CODE_OF_CONDUCT.md) of conduct
- [ ] Docs (If new inputs or outputs have been added or changes to behavior that should be documented. Please make a PR
in the [documentation repo](https://github.com/game-ci/documentation))
- [ ] Docs (If new inputs or outputs have been added or changes to behavior that should be documented. Please make
a PR in the [documentation repo](https://github.com/game-ci/documentation))
- [ ] Readme (updated or not needed)
- [ ] Tests (added, updated or not needed)

View File

@@ -13,7 +13,7 @@ jobs:
id: requestActivationFile
uses: game-ci/unity-request-activation-file@v2.0-alpha-1
- name: Upload activation file
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v2
with:
name: ${{ steps.requestActivationFile.outputs.filePath }}
path: ${{ steps.requestActivationFile.outputs.filePath }}

View File

@@ -3,13 +3,15 @@ name: Builds - MacOS
on:
workflow_dispatch:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
buildForAllPlatformsMacOS:
buildForAllPlatformsWindows:
name: ${{ matrix.targetPlatform }} on ${{ matrix.unityVersion }}
runs-on: macos-latest
strategy:
@@ -18,32 +20,26 @@ jobs:
projectPath:
- test-project
unityVersion:
- 2021.3.45f1
- 2022.3.13f1
- 2023.2.2f1
- 2021.3.29f1
- 2022.1.24f1
- 2022.2.21f1
- 2022.3.7f1
- 2023.1.8f1
targetPlatform:
- StandaloneOSX # Build a MacOS executable
- iOS # Build an iOS executable
include:
# Additionally test enableGpu build for a standalone windows target
- unityVersion: 6000.0.36f1
targetPlatform: StandaloneOSX
- unityVersion: 6000.0.36f1
targetPlatform: StandaloneOSX
buildProfile: 'Assets/Settings/Build Profiles/Sample macOS Build Profile.asset'
steps:
###########################
# Checkout #
###########################
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
lfs: true
###########################
# Cache #
###########################
- uses: actions/cache@v4
- uses: actions/cache@v3
with:
path: ${{ matrix.projectPath }}/Library
key: Library-${{ matrix.projectPath }}-macos-${{ matrix.targetPlatform }}
@@ -66,13 +62,10 @@ jobs:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
buildName: 'GameCI Test Build'
projectPath: ${{ matrix.projectPath }}
unityVersion: ${{ matrix.unityVersion }}
targetPlatform: ${{ matrix.targetPlatform }}
buildProfile: ${{ matrix.buildProfile }}
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue
# We use dirty build because we are replacing the default project settings file above
allowDirtyBuild: true
@@ -80,8 +73,8 @@ jobs:
###########################
# Upload #
###########################
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v3
with:
name: Build ${{ matrix.targetPlatform }} on MacOS (${{ matrix.unityVersion }})${{ matrix.buildProfile && ' With Build Profile' || '' }}
name: Build MacOS (${{ matrix.unityVersion }})
path: build
retention-days: 14

View File

@@ -3,6 +3,11 @@ name: Builds - Ubuntu
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
paths-ignore:
- '.github/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -36,8 +41,7 @@ env:
jobs:
buildForAllPlatformsUbuntu:
name:
"${{ matrix.targetPlatform }} on ${{ matrix.unityVersion}}${{startsWith(matrix.buildProfile, 'Assets') && ' (via Build Profile)' || '' }}"
name: ${{ matrix.targetPlatform }} on ${{ matrix.unityVersion }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -48,73 +52,36 @@ jobs:
projectPath:
- test-project
unityVersion:
- 2021.3.32f1
- 2022.3.13f1
- 2023.2.2f1
- 2021.3.29f1
- 2022.1.24f1
- 2022.2.21f1
- 2022.3.7f1
- 2023.1.8f1
targetPlatform:
- StandaloneOSX # Build a macOS standalone (Intel 64-bit) with mono backend.
- StandaloneWindows64 # Build a Windows 64-bit standalone with mono backend.
- StandaloneLinux64 # Build a Linux 64-bit standalone with mono/il2cpp backend.
- iOS # Build an iOS project.
- StandaloneLinux64 # Build a Linux 64-bit standalone with mono backend.
- iOS # Build an iOS player.
- Android # Build an Android .apk.
- WebGL # WebGL.
buildWithIl2cpp:
- false
- true
additionalParameters:
- -param value
- -standaloneBuildSubtarget Server
# Skipping configurations that are not supported
exclude:
# No il2cpp support on Linux Host
- targetPlatform: StandaloneOSX
buildWithIl2cpp: true
- targetPlatform: StandaloneWindows64
buildWithIl2cpp: true
# Only builds with Il2cpp
- targetPlatform: iOS
buildWithIl2cpp: false
- targetPlatform: Android
buildWithIl2cpp: false
- targetPlatform: WebGL
buildWithIl2cpp: false
# No dedicated server support
- targetPlatform: WebGL
additionalParameters: -standaloneBuildSubtarget Server
- targetPlatform: Android
additionalParameters: -standaloneBuildSubtarget Server
- targetPlatform: iOS
additionalParameters: -standaloneBuildSubtarget Server
# No dedicated server support on Linux Host
- targetPlatform: StandaloneOSX
additionalParameters: -standaloneBuildSubtarget Server
# No il2cpp dedicated server support on Linux Host
- targetPlatform: StandaloneWindows64
additionalParameters: -standaloneBuildSubtarget Server
buildWithIl2cpp: true
include:
- unityVersion: 6000.0.36f1
targetPlatform: WebGL
- unityVersion: 6000.0.36f1
targetPlatform: WebGL
buildProfile: 'Assets/Settings/Build Profiles/Sample WebGL Build Profile.asset'
# - StandaloneWindows # Build a Windows standalone.
# - WSAPlayer # Build an Windows Store Apps player.
# - PS4 # Build a PS4 Standalone.
# - XboxOne # Build a Xbox One Standalone.
# - tvOS # Build to Apple's tvOS platform.
# - Switch # Build a Nintendo Switch player
steps:
- name: Clear Space for Android Build
if: matrix.targetPlatform == 'Android'
uses: jlumbroso/free-disk-space@v1.3.1
###########################
# Checkout #
###########################
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
lfs: true
###########################
# Cache #
###########################
- uses: actions/cache@v4
- uses: actions/cache@v3
with:
path: ${{ matrix.projectPath }}/Library
key: Library-${{ matrix.projectPath }}-ubuntu-${{ matrix.targetPlatform }}
@@ -122,85 +89,22 @@ jobs:
Library-${{ matrix.projectPath }}-ubuntu-
Library-
###########################
# Set Scripting Backend #
###########################
- name: Set Scripting Backend To il2cpp
if: matrix.buildWithIl2cpp == true
run: |
mv -f "./test-project/ProjectSettings/ProjectSettingsIl2cpp.asset" "./test-project/ProjectSettings/ProjectSettings.asset"
###########################
# Build #
###########################
- name: Build
uses: ./
id: build-1
continue-on-error: true
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
- uses: ./
with:
buildName: 'GameCI Test Build'
projectPath: ${{ matrix.projectPath }}
buildProfile: ${{ matrix.buildProfile }}
unityVersion: ${{ matrix.unityVersion }}
targetPlatform: ${{ matrix.targetPlatform }}
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue ${{ matrix.additionalParameters }}
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue
providerStrategy: ${{ matrix.providerStrategy }}
allowDirtyBuild: true
- name: Sleep for Retry
if: ${{ steps.build-1.outcome == 'failure' }}
run: |
sleep 60
- name: Build (Retry 1)
uses: ./
id: build-2
if: ${{ steps.build-1.outcome == 'failure' }}
continue-on-error: true
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
buildName: 'GameCI Test Build'
projectPath: ${{ matrix.projectPath }}
buildProfile: ${{ matrix.buildProfile }}
unityVersion: ${{ matrix.unityVersion }}
targetPlatform: ${{ matrix.targetPlatform }}
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue ${{ matrix.additionalParameters }}
providerStrategy: ${{ matrix.providerStrategy }}
allowDirtyBuild: true
- name: Sleep for Retry
if: ${{ steps.build-2.outcome == 'failure' }}
run: |
sleep 240
- name: Build (Retry 2)
uses: ./
id: build-3
if: ${{ steps.build-2.outcome == 'failure' }}
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
buildName: 'GameCI Test Build'
projectPath: ${{ matrix.projectPath }}
buildProfile: ${{ matrix.buildProfile }}
unityVersion: ${{ matrix.unityVersion }}
targetPlatform: ${{ matrix.targetPlatform }}
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue ${{ matrix.additionalParameters }}
providerStrategy: ${{ matrix.providerStrategy }}
allowDirtyBuild: true
###########################
# Upload #
###########################
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v3
with:
name:
"Build ${{ matrix.targetPlatform }}${{ startsWith(matrix.buildProfile, 'Assets') && ' (via Build Profile)' || '' }} on Ubuntu (${{ matrix.unityVersion }}_il2cpp_${{ matrix.buildWithIl2cpp }}_params_${{ matrix.additionalParameters }})"
name: Build Ubuntu (${{ matrix.unityVersion }})
path: build
retention-days: 14

View File

@@ -3,6 +3,8 @@ name: Builds - Windows
on:
workflow_dispatch:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -11,47 +13,36 @@ concurrency:
jobs:
buildForAllPlatformsWindows:
name: ${{ matrix.targetPlatform }} on ${{ matrix.unityVersion }}
runs-on: windows-2022
runs-on: windows-2019
strategy:
fail-fast: false
matrix:
projectPath:
- test-project
unityVersion:
- 2021.3.32f1
- 2022.3.13f1
- 2023.2.2f1
- 2021.3.29f1
- 2022.1.24f1
- 2022.2.21f1
- 2022.3.7f1
- 2023.1.8f1
targetPlatform:
- Android # Build an Android apk.
- StandaloneWindows64 # Build a Windows 64-bit standalone.
- StandaloneWindows # Build a Windows 32-bit standalone.
- WSAPlayer # Build a UWP App
- tvOS # Build an Apple TV XCode project
enableGpu:
- false
include:
# Additionally test enableGpu build for a standalone windows target
- projectPath: test-project
unityVersion: 2023.2.2f1
targetPlatform: StandaloneWindows64
enableGpu: true
- unityVersion: 6000.0.36f1
targetPlatform: StandaloneWindows64
- unityVersion: 6000.0.36f1
targetPlatform: StandaloneWindows64
buildProfile: 'Assets/Settings/Build Profiles/Sample Windows Build Profile.asset'
steps:
###########################
# Checkout #
###########################
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
lfs: true
###########################
# Cache #
###########################
- uses: actions/cache@v4
- uses: actions/cache@v3
with:
path: ${{ matrix.projectPath }}/Library
key: Library-${{ matrix.projectPath }}-windows-${{ matrix.targetPlatform }}
@@ -78,14 +69,10 @@ jobs:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
buildName: 'GameCI Test Build'
projectPath: ${{ matrix.projectPath }}
unityVersion: ${{ matrix.unityVersion }}
targetPlatform: ${{ matrix.targetPlatform }}
buildProfile: ${{ matrix.buildProfile }}
enableGpu: ${{ matrix.enableGpu }}
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue
allowDirtyBuild: true
# We use dirty build because we are replacing the default project settings file above
@@ -105,13 +92,10 @@ jobs:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
buildName: 'GameCI Test Build'
projectPath: ${{ matrix.projectPath }}
unityVersion: ${{ matrix.unityVersion }}
targetPlatform: ${{ matrix.targetPlatform }}
enableGpu: ${{ matrix.enableGpu }}
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue
allowDirtyBuild: true
# We use dirty build because we are replacing the default project settings file above
@@ -130,13 +114,10 @@ jobs:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
buildName: 'GameCI Test Build'
projectPath: ${{ matrix.projectPath }}
unityVersion: ${{ matrix.unityVersion }}
targetPlatform: ${{ matrix.targetPlatform }}
enableGpu: ${{ matrix.enableGpu }}
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue
allowDirtyBuild: true
# We use dirty build because we are replacing the default project settings file above
@@ -144,8 +125,8 @@ jobs:
###########################
# Upload #
###########################
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v3
with:
name: Build ${{ matrix.targetPlatform }} on Windows (${{ matrix.unityVersion }})${{ matrix.enableGpu && ' With GPU' || '' }}${{ matrix.buildProfile && ' With Build Profile' || '' }}
name: Build Windows (${{ matrix.unityVersion }})
path: build
retention-days: 14

View File

@@ -15,24 +15,23 @@ jobs:
cleanupCloudRunner:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
if: github.event.event_type != 'pull_request_target'
with:
lfs: true
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: yarn
# Commented out: Using LocalStack tests instead of real AWS
# - run: yarn run cli --help
# env:
# AWS_REGION: eu-west-2
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_DEFAULT_REGION: eu-west-2
# - run: yarn run cli -m list-resources
# env:
# AWS_REGION: eu-west-2
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_DEFAULT_REGION: eu-west-2
- run: yarn run cli --help
env:
AWS_REGION: eu-west-2
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: eu-west-2
- run: yarn run cli -m list-resources
env:
AWS_REGION: eu-west-2
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: eu-west-2

View File

@@ -19,12 +19,11 @@ env:
GCP_LOGGING: true
GCP_PROJECT: unitykubernetesbuilder
GCP_LOG_FILE: ${{ github.workspace }}/cloud-runner-logs.txt
# Commented out: Using LocalStack tests instead of real AWS
# AWS_REGION: eu-west-2
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_DEFAULT_REGION: eu-west-2
# AWS_STACK_NAME: game-ci-github-pipelines
AWS_REGION: eu-west-2
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: eu-west-2
AWS_STACK_NAME: game-ci-github-pipelines
CLOUD_RUNNER_BRANCH: ${{ github.ref }}
CLOUD_RUNNER_DEBUG: true
CLOUD_RUNNER_DEBUG_TREE: true
@@ -50,8 +49,7 @@ jobs:
cloudRunnerTests: true
versioning: None
CLOUD_RUNNER_CLUSTER: local-docker
# Commented out: Using LocalStack tests instead of real AWS
# AWS_STACK_NAME: game-ci-github-pipelines
AWS_STACK_NAME: game-ci-github-pipelines
CHECKS_UPDATE: ${{ github.event.inputs.checksObject }}
run: |
git clone -b cloud-runner-develop https://github.com/game-ci/unity-builder

View File

@@ -0,0 +1,190 @@
name: Cloud Runner CI Pipeline
on:
push: { branches: [cloud-runner-develop, cloud-runner-preview, main] }
workflow_dispatch:
permissions:
checks: write
contents: read
actions: write
env:
GKE_ZONE: 'us-central1'
GKE_REGION: 'us-central1'
GKE_PROJECT: 'unitykubernetesbuilder'
GKE_CLUSTER: 'game-ci-github-pipelines'
GCP_LOGGING: true
GCP_PROJECT: unitykubernetesbuilder
GCP_LOG_FILE: ${{ github.workspace }}/cloud-runner-logs.txt
AWS_REGION: eu-west-2
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: eu-west-2
AWS_STACK_NAME: game-ci-team-pipelines
CLOUD_RUNNER_BRANCH: ${{ github.ref }}
DEBUG: true
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
PROJECT_PATH: test-project
UNITY_VERSION: 2019.3.15f1
USE_IL2CPP: false
USE_GKE_GCLOUD_AUTH_PLUGIN: true
GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
smokeTests:
name: Smoke Tests
if: github.event.event_type != 'pull_request_target'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
test:
#- 'cloud-runner-async-workflow'
- 'cloud-runner-caching'
# - 'cloud-runner-end2end-caching'
# - 'cloud-runner-end2end-retaining'
- 'cloud-runner-environment'
- 'cloud-runner-hooks'
- 'cloud-runner-local-persistence'
- 'cloud-runner-locking-core'
- 'cloud-runner-locking-get-locked'
providerStrategy:
#- aws
- local-docker
#- k8s
steps:
- name: Checkout (default)
uses: actions/checkout@v3
with:
lfs: false
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-2
- uses: google-github-actions/auth@v1
if: matrix.providerStrategy == 'k8s'
with:
credentials_json: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
- name: 'Set up Cloud SDK'
if: matrix.providerStrategy == 'k8s'
uses: 'google-github-actions/setup-gcloud@v1.1.0'
- name: Get GKE cluster credentials
if: matrix.providerStrategy == 'k8s'
run: |
export USE_GKE_GCLOUD_AUTH_PLUGIN=True
gcloud components install gke-gcloud-auth-plugin
gcloud container clusters get-credentials $GKE_CLUSTER --zone $GKE_ZONE --project $GKE_PROJECT
- run: yarn
- run: yarn run test "${{ matrix.test }}" --detectOpenHandles --forceExit --runInBand
timeout-minutes: 35
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
PROJECT_PATH: test-project
TARGET_PLATFORM: StandaloneWindows64
cloudRunnerTests: true
versioning: None
CLOUD_RUNNER_CLUSTER: ${{ matrix.providerStrategy }}
tests:
# needs:
# - smokeTests
# - buildTargetTests
name: Integration Tests
if: github.event.event_type != 'pull_request_target'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
providerStrategy:
- aws
- local-docker
- k8s
test:
- 'cloud-runner-async-workflow'
#- 'cloud-runner-caching'
- 'cloud-runner-end2end-locking'
- 'cloud-runner-end2end-caching'
- 'cloud-runner-end2end-retaining'
- 'cloud-runner-environment'
#- 'cloud-runner-hooks'
- 'cloud-runner-s3-steps'
#- 'cloud-runner-local-persistence'
#- 'cloud-runner-locking-core'
#- 'cloud-runner-locking-get-locked'
steps:
- name: Checkout (default)
uses: actions/checkout@v2
with:
lfs: false
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-2
- uses: google-github-actions/auth@v1
if: matrix.providerStrategy == 'k8s'
with:
credentials_json: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
- name: 'Set up Cloud SDK'
if: matrix.providerStrategy == 'k8s'
uses: 'google-github-actions/setup-gcloud@v1.1.0'
- name: Get GKE cluster credentials
if: matrix.providerStrategy == 'k8s'
run: |
export USE_GKE_GCLOUD_AUTH_PLUGIN=True
gcloud components install gke-gcloud-auth-plugin
gcloud container clusters get-credentials $GKE_CLUSTER --zone $GKE_ZONE --project $GKE_PROJECT
- run: yarn
- run: yarn run test "${{ matrix.test }}" --detectOpenHandles --forceExit --runInBand
timeout-minutes: 60
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
PROJECT_PATH: test-project
TARGET_PLATFORM: StandaloneWindows64
cloudRunnerTests: true
versioning: None
PROVIDER_STRATEGY: ${{ matrix.providerStrategy }}
buildTargetTests:
name: Local Build Target Tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
providerStrategy:
#- aws
- local-docker
#- k8s
targetPlatform:
- StandaloneOSX # Build a macOS standalone (Intel 64-bit).
- StandaloneWindows64 # Build a Windows 64-bit standalone.
- StandaloneLinux64 # Build a Linux 64-bit standalone.
- WebGL # WebGL.
- iOS # Build an iOS player.
- Android # Build an Android .apk.
steps:
- name: Checkout (default)
uses: actions/checkout@v3
with:
lfs: false
- run: yarn
- uses: ./
id: unity-build
timeout-minutes: 30
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
cloudRunnerTests: true
versioning: None
targetPlatform: ${{ matrix.targetPlatform }}
providerStrategy: ${{ matrix.providerStrategy }}
- run: |
cp ./cloud-runner-cache/cache/${{ steps.unity-build.outputs.CACHE_KEY }}/build/${{ steps.unity-build.outputs.BUILD_ARTIFACT }} ${{ steps.unity-build.outputs.BUILD_ARTIFACT }}
- uses: actions/upload-artifact@v3
with:
name: ${{ matrix.providerStrategy }} Build (${{ matrix.targetPlatform }})
path: ${{ steps.unity-build.outputs.BUILD_ARTIFACT }}
retention-days: 14

View File

@@ -1,113 +0,0 @@
name: cloud-runner-integrity-localstack
on:
workflow_call:
inputs:
runGithubIntegrationTests:
description: 'Run GitHub Checks integration tests'
required: false
default: 'false'
type: string
permissions:
contents: read
checks: write
statuses: write
env:
AWS_REGION: us-east-1
AWS_DEFAULT_REGION: us-east-1
AWS_STACK_NAME: game-ci-local
AWS_ENDPOINT: http://localhost:4566
AWS_ENDPOINT_URL: http://localhost:4566
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_FORCE_PROVIDER: aws
CLOUD_RUNNER_BRANCH: ${{ github.ref }}
DEBUG: true
PROJECT_PATH: test-project
USE_IL2CPP: false
# Increase CloudFormation stack wait time (GitHub Actions runners can be slow)
CLOUD_RUNNER_AWS_STACK_WAIT_TIME: 900
jobs:
tests:
name: Cloud Runner Tests (LocalStack)
runs-on: ubuntu-latest
services:
localstack:
image: localstack/localstack
ports:
- 4566:4566
env:
SERVICES: cloudformation,ecs,kinesis,cloudwatch,s3,logs
strategy:
fail-fast: false
matrix:
test:
- 'cloud-runner-end2end-locking'
- 'cloud-runner-end2end-caching'
- 'cloud-runner-end2end-retaining'
- 'cloud-runner-caching'
- 'cloud-runner-environment'
- 'cloud-runner-image'
- 'cloud-runner-hooks'
- 'cloud-runner-local-persistence'
- 'cloud-runner-locking-core'
- 'cloud-runner-locking-get-locked'
steps:
- uses: actions/checkout@v4
with:
lfs: false
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
- name: Verify LocalStack is running and accessible
run: |
echo "Verifying LocalStack services are available..."
# Wait for LocalStack to be ready
for i in {1..30}; do
if curl -s http://localhost:4566/_localstack/health | grep -q '"services":'; then
echo "LocalStack is ready"
curl -s http://localhost:4566/_localstack/health | jq '.' || curl -s http://localhost:4566/_localstack/health
break
fi
echo "Waiting for LocalStack... ($i/30)"
sleep 2
done
# Verify required AWS services are available
echo "Verifying required AWS services (cloudformation,ecs,kinesis,cloudwatch,s3,logs)..."
curl -s http://localhost:4566/_localstack/health | grep -q 'cloudformation' || echo "WARNING: CloudFormation service may not be available"
curl -s http://localhost:4566/_localstack/health | grep -q 'ecs' || echo "WARNING: ECS service may not be available"
curl -s http://localhost:4566/_localstack/health | grep -q 'kinesis' || echo "WARNING: Kinesis service may not be available"
- run: yarn install --frozen-lockfile
- name: Validate AWS provider configuration
run: |
echo "Validating AWS provider configuration for LocalStack tests..."
echo "PROVIDER_STRATEGY: aws"
echo "AWS_FORCE_PROVIDER: ${{ env.AWS_FORCE_PROVIDER }}"
echo "AWS_ENDPOINT: ${{ env.AWS_ENDPOINT }}"
echo ""
echo "✓ Configuration validated: AWS provider will be used with LocalStack to validate AWS functionality"
echo "✓ This ensures ECS, CloudFormation, Kinesis, and other AWS services are properly tested"
echo "✓ AWS_FORCE_PROVIDER prevents automatic fallback to local-docker"
- run: yarn run test "${{ matrix.test }}" --detectOpenHandles --forceExit --runInBand
timeout-minutes: 60
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
PROJECT_PATH: test-project
TARGET_PLATFORM: StandaloneWindows64
cloudRunnerTests: true
versioning: None
KUBE_STORAGE_CLASS: local-path
PROVIDER_STRATEGY: aws
AWS_FORCE_PROVIDER: aws
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_ENDPOINT: http://localhost:4566
AWS_ENDPOINT_URL: http://localhost:4566
GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,6 @@ on:
push: { branches: [main] }
pull_request: {}
permissions:
contents: read
checks: write
statuses: write
env:
CODECOV_TOKEN: '2f2eb890-30e2-4724-83eb-7633832cf0de'
@@ -21,18 +16,13 @@ jobs:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: yarn
- run: yarn lint
- run: yarn test:ci --coverage
- run: yarn test --coverage
- run: bash <(curl -s https://codecov.io/bash)
- run: yarn build || { echo "build command should always succeed" ; exit 61; }
# - run: yarn build --quiet && git diff --quiet dist || { echo "dist should be auto generated" ; git diff dist ; exit 62; }
cloud-runner:
name: Cloud Runner Integrity
uses: ./.github/workflows/cloud-runner-integrity.yml
secrets: inherit
# - run: yarn build --quiet && git diff --quiet dist || { echo "dist should be auto generated" ; git diff dist ; exit 62; }

7
.vscode/launch.json vendored
View File

@@ -1,12 +1,5 @@
{
"configurations": [
{
"name": "PowerShell Launch Current File",
"type": "PowerShell",
"request": "launch",
"script": "${file}",
"cwd": "${cwd}"
},
{
"type": "node",
"request": "launch",

View File

@@ -25,7 +25,7 @@ Steps to be performed to submit a pull request:
#### Pull Request Prerequisites
You have [Node](https://nodejs.org/) installed at v18+ and [Yarn](https://yarnpkg.com/) at v1.22.0+.
You have [Node](https://nodejs.org/) installed at v12.2.0+ and [Yarn](https://yarnpkg.com/) at v1.18.0+.
Please note that commit hooks will run automatically to perform some tasks;
@@ -36,8 +36,7 @@ Please note that commit hooks will run automatically to perform some tasks;
#### Windows users
Make sure your editor and terminal that run the tests are set to `Powershell 7` or above with
`Git's Unix tools for Windows` installed. This is because some tests require you to be able to run `sh` and other
unix commands.
`Git's Unix tools for Windows` installed. Some tests require you to be able to run `sh` and other unix commands.
#### License

View File

@@ -18,11 +18,7 @@ inputs:
projectPath:
required: false
default: ''
description: 'Path to the project to be built, relative to the repository root.'
buildProfile:
required: false
default: ''
description: 'Path to the build profile to activate, relative to the project root.'
description: 'Relative path to the project to be built.'
buildName:
required: false
default: ''
@@ -39,10 +35,6 @@ inputs:
required: false
default: ''
description: 'Suppresses `-quit`. Exit your build method using `EditorApplication.Exit(0)` instead.'
enableGpu:
required: false
default: ''
description: 'Launches unity without specifying `-nographics`.'
customParameters:
required: false
default: ''
@@ -109,43 +101,11 @@ inputs:
required: false
default: ''
description: '[CloudRunner] GitHub owner name or organization/team name'
runAsHostUser:
required: false
default: 'false'
description:
'Whether to run as a user that matches the host system or the default root container user. Only applicable to
Linux hosts and containers. This is useful for fixing permission errors on Self-Hosted runners.'
chownFilesTo:
required: false
default: ''
description:
'User and optionally group (user or user:group or uid:gid) to give ownership of the resulting build artifacts'
dockerCpuLimit:
required: false
default: ''
description: 'Number of CPU cores to assign the docker container. Defaults to all available cores on all platforms.'
dockerMemoryLimit:
required: false
default: ''
description:
'Amount of memory to assign the docker container. Defaults to 95% of total system memory rounded down to the
nearest megabyte on Linux and 80% on Windows. On unrecognized platforms, defaults to 75% of total system memory.
To manually specify a value, use the format <number><unit>, where unit is either m or g. ie: 512m = 512 megabytes'
dockerIsolationMode:
required: false
default: 'default'
description:
'Isolation mode to use for the docker container. Can be one of process, hyperv, or default. Default will pick the
default mode as described by Microsoft where server versions use process and desktop versions use hyperv. Only
applicable on Windows'
containerRegistryRepository:
required: false
default: 'unityci/editor'
description: 'Container registry and repository to pull image from. Only applicable if customImage is not set.'
containerRegistryImageVersion:
required: false
default: '3'
description: 'Container registry image version. Only applicable if customImage is not set.'
allowDirtyBuild:
required: false
default: ''
@@ -194,15 +154,11 @@ inputs:
description:
'[CloudRunner] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
be configured.'
resourceTracking:
default: 'false'
required: false
description: '[CloudRunner] Enable resource tracking logs for disk usage and allocation summaries.'
containerCpu:
cloudRunnerCpu:
default: ''
required: false
description: '[CloudRunner] Amount of CPU time to assign the remote build container'
containerMemory:
cloudRunnerMemory:
default: ''
required: false
description: '[CloudRunner] Amount of memory to assign the remote build container'
@@ -265,20 +221,6 @@ inputs:
description:
'The path to mount the workspace inside the docker container. For windows, leave out the drive letter. For example
c:/github/workspace should be defined as /github/workspace'
skipActivation:
default: 'false'
required: false
description: 'Skip the activation/deactivation of Unity. This assumes Unity is already activated.'
cloneDepth:
default: '50'
required: false
description: '[CloudRunner] Specifies the depth of the git clone for the repository. Use 0 for full clone.'
cloudRunnerRepoName:
default: 'game-ci/unity-builder'
required: false
description:
'[CloudRunner] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
fixes.'
outputs:
volume:
@@ -287,14 +229,9 @@ outputs:
description: 'The generated version used for the Unity build'
androidVersionCode:
description: 'The generated versionCode used for the Android Unity build'
engineExitCode:
description:
'Returns the exit code from the build scripts. This code is 0 if the build was successful. If there was an error
during activation, the code is from the activation step. If activation is successful, the code is from the project
build step.'
branding:
icon: 'box'
color: 'gray-dark'
runs:
using: 'node20'
using: 'node16'
main: 'dist/index.js'

BIN
dist/BlankProject/Packages/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,10 @@
{
"m_SettingKeys": [
"VR Device Disabled",
"VR Device User Alert"
],
"m_SettingValues": [
"False",
"False"
]
}

View File

@@ -0,0 +1,709 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<LangVersion>latest</LangVersion>
<_TargetFrameworkDirectories>non_empty_path_generated_by_unity.rider.package</_TargetFrameworkDirectories>
<_FullFrameworkReferenceAssemblyPaths>non_empty_path_generated_by_unity.rider.package</_FullFrameworkReferenceAssemblyPaths>
<DisableHandlePackageFileConflicts>true</DisableHandlePackageFileConflicts>
</PropertyGroup>
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProductVersion>10.0.20506</ProductVersion>
<SchemaVersion>2.0</SchemaVersion>
<RootNamespace></RootNamespace>
<ProjectGuid>{B7F8614B-1EC2-9D3A-DA1C-4D279A867D74}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<AssemblyName>Assembly-CSharp-Editor</AssemblyName>
<TargetFrameworkVersion>v4.7.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<BaseDirectory>.</BaseDirectory>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>Temp\bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE;UNITY_2019_2_11;UNITY_2019_2;UNITY_2019;UNITY_5_3_OR_NEWER;UNITY_5_4_OR_NEWER;UNITY_5_5_OR_NEWER;UNITY_5_6_OR_NEWER;UNITY_2017_1_OR_NEWER;UNITY_2017_2_OR_NEWER;UNITY_2017_3_OR_NEWER;UNITY_2017_4_OR_NEWER;UNITY_2018_1_OR_NEWER;UNITY_2018_2_OR_NEWER;UNITY_2018_3_OR_NEWER;UNITY_2018_4_OR_NEWER;UNITY_2019_1_OR_NEWER;UNITY_2019_2_OR_NEWER;PLATFORM_ARCH_64;UNITY_64;UNITY_INCLUDE_TESTS;ENABLE_AUDIO;ENABLE_CACHING;ENABLE_CLOTH;ENABLE_MICROPHONE;ENABLE_MULTIPLE_DISPLAYS;ENABLE_PHYSICS;ENABLE_TEXTURE_STREAMING;ENABLE_UNET;ENABLE_LZMA;ENABLE_UNITYEVENTS;ENABLE_WEBCAM;ENABLE_WWW;ENABLE_CLOUD_SERVICES_COLLAB;ENABLE_CLOUD_SERVICES_COLLAB_SOFTLOCKS;ENABLE_CLOUD_SERVICES_ADS;ENABLE_CLOUD_SERVICES_USE_WEBREQUEST;ENABLE_CLOUD_SERVICES_UNET;ENABLE_CLOUD_SERVICES_BUILD;ENABLE_CLOUD_LICENSE;ENABLE_EDITOR_HUB_LICENSE;ENABLE_WEBSOCKET_CLIENT;ENABLE_DIRECTOR_AUDIO;ENABLE_DIRECTOR_TEXTURE;ENABLE_MANAGED_JOBS;ENABLE_MANAGED_TRANSFORM_JOBS;ENABLE_MANAGED_ANIMATION_JOBS;ENABLE_MANAGED_AUDIO_JOBS;INCLUDE_DYNAMIC_GI;ENABLE_MONO_BDWGC;ENABLE_SCRIPTING_GC_WBARRIERS;PLATFORM_SUPPORTS_MONO;RENDER_SOFTWARE_CURSOR;ENABLE_VIDEO;PLATFORM_STANDALONE_WIN;PLATFORM_STANDALONE;UNITY_STANDALONE_WIN;UNITY_STANDALONE;ENABLE_RUNTIME_GI;ENABLE_MOVIES;ENABLE_NETWORK;ENABLE_CRUNCH_TEXTURE_COMPRESSION;ENABLE_UNITYWEBREQUEST;ENABLE_CLOUD_SERVICES;ENABLE_CLOUD_SERVICES_ANALYTICS;ENABLE_CLOUD_SERVICES_PURCHASING;ENABLE_CLOUD_SERVICES_CRASH_REPORTING;ENABLE_OUT_OF_PROCESS_CRASH_HANDLER;ENABLE_EVENT_QUEUE;ENABLE_CLUSTER_SYNC;ENABLE_CLUSTERINPUT;ENABLE_VR;ENABLE_AR;ENABLE_WEBSOCKET_HOST;ENABLE_MONO;NET_STANDARD_2_0;ENABLE_PROFILER;UNITY_ASSERTIONS;UNITY_EDITOR;UNITY_EDITOR_64;UNITY_EDITOR_WIN;ENABLE_UNITY_COLLECTIONS_CHECKS;ENABLE_BURST_AOT;UNITY_TEAM_LICENSE;ENABLE_CUSTOM_RENDER_TEXTURE;ENABLE_DIRECTOR;ENABLE_LOCALIZATION;ENABLE_SPRITES;ENABLE_TERRAIN;ENABLE_TILEMAP;ENABLE_TIMELINE;ENABLE_LEGACY_INPUT_MANAGER;NET_4_6;CSHARP_7_OR_LATER;CSHARP_7_3_OR_NEWER</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<NoWarn>0169</NoWarn>
<AllowUnsafeBlocks>False</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>Temp\bin\Release\</OutputPath>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<NoWarn>0169</NoWarn>
<AllowUnsafeBlocks>False</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup>
<NoConfig>true</NoConfig>
<NoStdLib>true</NoStdLib>
<AddAdditionalExplicitAssemblyReferences>false</AddAdditionalExplicitAssemblyReferences>
<ImplicitlyExpandNETStandardFacades>false</ImplicitlyExpandNETStandardFacades>
<ImplicitlyExpandDesignTimeFacades>false</ImplicitlyExpandDesignTimeFacades>
</PropertyGroup>
<ItemGroup>
<Reference Include="UnityEngine">
<HintPath>C:\Program Files\Unity\2019.2.11f1\Editor\Data\Managed/UnityEngine/UnityEngine.dll</HintPath>
</Reference>
<Reference Include="UnityEditor">
<HintPath>C:\Program Files\Unity\2019.2.11f1\Editor\Data\Managed/UnityEditor.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="Assets\Editor\Builder.cs" />
<Compile Include="Assets\Editor\Input\ArgumentsParser.cs" />
<Compile Include="Assets\Editor\Reporting\StdOutReporter.cs" />
<Compile Include="Assets\Editor\System\ProcessExtensions.cs" />
<Compile Include="Assets\Editor\Versioning\VersionApplicator.cs" />
<Compile Include="Assets\Editor\Versioning\Git.cs" />
<Compile Include="Assets\Editor\Versioning\VersionGenerator.cs" />
<Compile Include="Assets\Editor\Versioning\GitException.cs" />
<Reference Include="UnityEditor.TestRunner">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/UnityEditor.TestRunner.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.TestRunner">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/UnityEngine.TestRunner.dll</HintPath>
</Reference>
<Reference Include="Unity.Timeline.Editor">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/Unity.Timeline.Editor.dll</HintPath>
</Reference>
<Reference Include="com.unity.multiplayer-hlapi.Editor">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/com.unity.multiplayer-hlapi.Editor.dll</HintPath>
</Reference>
<Reference Include="Unity.VSCode.Editor">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/Unity.VSCode.Editor.dll</HintPath>
</Reference>
<Reference Include="Unity.TextMeshPro.Editor">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/Unity.TextMeshPro.Editor.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UI">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/UnityEngine.UI.dll</HintPath>
</Reference>
<Reference Include="Unity.Timeline">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/Unity.Timeline.dll</HintPath>
</Reference>
<Reference Include="Unity.CollabProxy.Editor">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/Unity.CollabProxy.Editor.dll</HintPath>
</Reference>
<Reference Include="com.unity.multiplayer-weaver.Editor">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/com.unity.multiplayer-weaver.Editor.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.XR.LegacyInputHelpers">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/UnityEngine.XR.LegacyInputHelpers.dll</HintPath>
</Reference>
<Reference Include="Unity.Rider.Editor">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/Unity.Rider.Editor.dll</HintPath>
</Reference>
<Reference Include="Unity.2D.Sprite.Editor">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/Unity.2D.Sprite.Editor.dll</HintPath>
</Reference>
<Reference Include="Unity.2D.Tilemap.Editor">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/Unity.2D.Tilemap.Editor.dll</HintPath>
</Reference>
<Reference Include="UnityEditor.SpatialTracking">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/UnityEditor.SpatialTracking.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.SpatialTracking">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/UnityEngine.SpatialTracking.dll</HintPath>
</Reference>
<Reference Include="Unity.TextMeshPro">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/Unity.TextMeshPro.dll</HintPath>
</Reference>
<Reference Include="Unity.Analytics.DataPrivacy">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/Unity.Analytics.DataPrivacy.dll</HintPath>
</Reference>
<Reference Include="UnityEditor.XR.LegacyInputHelpers">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/UnityEditor.XR.LegacyInputHelpers.dll</HintPath>
</Reference>
<Reference Include="UnityEditor.UI">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/UnityEditor.UI.dll</HintPath>
</Reference>
<Reference Include="com.unity.multiplayer-hlapi.Runtime">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/ScriptAssemblies/com.unity.multiplayer-hlapi.Runtime.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.AIModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.AIModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.ARModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.ARModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.AccessibilityModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.AccessibilityModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.AndroidJNIModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.AndroidJNIModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.AnimationModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.AnimationModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.AssetBundleModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.AssetBundleModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.AudioModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.AudioModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.ClothModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.ClothModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.ClusterInputModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.ClusterInputModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.ClusterRendererModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.ClusterRendererModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.CoreModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.CrashReportingModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.CrashReportingModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.DSPGraphModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.DSPGraphModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.DirectorModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.DirectorModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.FileSystemHttpModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.FileSystemHttpModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.GameCenterModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.GameCenterModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.GridModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.GridModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.HotReloadModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.HotReloadModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.IMGUIModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.IMGUIModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.ImageConversionModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.ImageConversionModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.InputModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.InputModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.InputLegacyModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.InputLegacyModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.JSONSerializeModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.JSONSerializeModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.LocalizationModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.LocalizationModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.ParticleSystemModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.ParticleSystemModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.PerformanceReportingModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.PerformanceReportingModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.PhysicsModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.PhysicsModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.Physics2DModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.Physics2DModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.ProfilerModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.ProfilerModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.ScreenCaptureModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.ScreenCaptureModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.SharedInternalsModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.SharedInternalsModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.SpriteMaskModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.SpriteMaskModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.SpriteShapeModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.SpriteShapeModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.StreamingModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.StreamingModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.SubstanceModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.SubstanceModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.TLSModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.TLSModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.TerrainModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.TerrainModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.TerrainPhysicsModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.TerrainPhysicsModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.TextCoreModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.TextCoreModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.TextRenderingModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.TextRenderingModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.TilemapModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.TilemapModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UIModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.UIModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UIElementsModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.UIElementsModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UNETModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.UNETModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UmbraModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.UmbraModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UnityAnalyticsModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.UnityAnalyticsModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UnityConnectModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.UnityConnectModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UnityTestProtocolModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.UnityTestProtocolModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UnityWebRequestModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.UnityWebRequestModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UnityWebRequestAssetBundleModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.UnityWebRequestAssetBundleModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UnityWebRequestAudioModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.UnityWebRequestAudioModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UnityWebRequestTextureModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.UnityWebRequestTextureModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UnityWebRequestWWWModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.UnityWebRequestWWWModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.VFXModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.VFXModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.VRModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.VRModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.VehiclesModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.VehiclesModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.VideoModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.VideoModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.WindModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.WindModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.XRModule">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEngine/UnityEngine.XRModule.dll</HintPath>
</Reference>
<Reference Include="UnityEditor.VR">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/UnityExtensions/Unity/UnityVR/Editor/UnityEditor.VR.dll</HintPath>
</Reference>
<Reference Include="UnityEditor.Graphs">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/Managed/UnityEditor.Graphs.dll</HintPath>
</Reference>
<Reference Include="UnityEditor.WindowsStandalone.Extensions">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/PlaybackEngines/WindowsStandaloneSupport/UnityEditor.WindowsStandalone.Extensions.dll</HintPath>
</Reference>
<Reference Include="UnityEditor.WebGL.Extensions">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/PlaybackEngines/WebGLSupport/UnityEditor.WebGL.Extensions.dll</HintPath>
</Reference>
<Reference Include="UnityEditor.Android.Extensions">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/PlaybackEngines/AndroidPlayer/UnityEditor.Android.Extensions.dll</HintPath>
</Reference>
<Reference Include="UnityEditor.UWP.Extensions">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/PlaybackEngines/MetroSupport/UnityEditor.UWP.Extensions.dll</HintPath>
</Reference>
<Reference Include="UnityEditor.Advertisements">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/PackageCache/com.unity.ads@2.0.8/Editor/UnityEditor.Advertisements.dll</HintPath>
</Reference>
<Reference Include="Unity.Analytics.Editor">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/PackageCache/com.unity.analytics@3.3.2/Unity.Analytics.Editor.dll</HintPath>
</Reference>
<Reference Include="Unity.Analytics.StandardEvents">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/PackageCache/com.unity.analytics@3.3.2/Unity.Analytics.StandardEvents.dll</HintPath>
</Reference>
<Reference Include="Unity.Analytics.Tracker">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/PackageCache/com.unity.analytics@3.3.2/Unity.Analytics.Tracker.dll</HintPath>
</Reference>
<Reference Include="UnityEditor.Purchasing">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/PackageCache/com.unity.purchasing@2.0.6/Editor/UnityEditor.Purchasing.dll</HintPath>
</Reference>
<Reference Include="nunit.framework">
<HintPath>C:/Repositories/unity-builder/builder/default-build-script/Library/PackageCache/com.unity.ext.nunit@1.0.0/net35/unity-custom/nunit.framework.dll</HintPath>
</Reference>
<Reference Include="mscorlib">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/mscorlib.dll</HintPath>
</Reference>
<Reference Include="System">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/System.dll</HintPath>
</Reference>
<Reference Include="System.Core">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/System.Core.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.Serialization">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/System.Runtime.Serialization.dll</HintPath>
</Reference>
<Reference Include="System.Xml">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/System.Xml.dll</HintPath>
</Reference>
<Reference Include="System.Xml.Linq">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/System.Xml.Linq.dll</HintPath>
</Reference>
<Reference Include="System.Numerics">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/System.Numerics.dll</HintPath>
</Reference>
<Reference Include="System.Numerics.Vectors">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/System.Numerics.Vectors.dll</HintPath>
</Reference>
<Reference Include="System.Net.Http">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/System.Net.Http.dll</HintPath>
</Reference>
<Reference Include="Microsoft.CSharp">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Microsoft.CSharp.dll</HintPath>
</Reference>
<Reference Include="System.Data">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/System.Data.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Win32.Primitives">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/Microsoft.Win32.Primitives.dll</HintPath>
</Reference>
<Reference Include="netstandard">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/netstandard.dll</HintPath>
</Reference>
<Reference Include="System.AppContext">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.AppContext.dll</HintPath>
</Reference>
<Reference Include="System.Collections.Concurrent">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Collections.Concurrent.dll</HintPath>
</Reference>
<Reference Include="System.Collections">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Collections.dll</HintPath>
</Reference>
<Reference Include="System.Collections.NonGeneric">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Collections.NonGeneric.dll</HintPath>
</Reference>
<Reference Include="System.Collections.Specialized">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Collections.Specialized.dll</HintPath>
</Reference>
<Reference Include="System.ComponentModel.Annotations">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.ComponentModel.Annotations.dll</HintPath>
</Reference>
<Reference Include="System.ComponentModel">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.ComponentModel.dll</HintPath>
</Reference>
<Reference Include="System.ComponentModel.EventBasedAsync">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.ComponentModel.EventBasedAsync.dll</HintPath>
</Reference>
<Reference Include="System.ComponentModel.Primitives">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.ComponentModel.Primitives.dll</HintPath>
</Reference>
<Reference Include="System.ComponentModel.TypeConverter">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.ComponentModel.TypeConverter.dll</HintPath>
</Reference>
<Reference Include="System.Console">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Console.dll</HintPath>
</Reference>
<Reference Include="System.Data.Common">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Data.Common.dll</HintPath>
</Reference>
<Reference Include="System.Diagnostics.Contracts">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Diagnostics.Contracts.dll</HintPath>
</Reference>
<Reference Include="System.Diagnostics.Debug">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Diagnostics.Debug.dll</HintPath>
</Reference>
<Reference Include="System.Diagnostics.FileVersionInfo">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Diagnostics.FileVersionInfo.dll</HintPath>
</Reference>
<Reference Include="System.Diagnostics.Process">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Diagnostics.Process.dll</HintPath>
</Reference>
<Reference Include="System.Diagnostics.StackTrace">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Diagnostics.StackTrace.dll</HintPath>
</Reference>
<Reference Include="System.Diagnostics.TextWriterTraceListener">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Diagnostics.TextWriterTraceListener.dll</HintPath>
</Reference>
<Reference Include="System.Diagnostics.Tools">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Diagnostics.Tools.dll</HintPath>
</Reference>
<Reference Include="System.Diagnostics.TraceSource">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Diagnostics.TraceSource.dll</HintPath>
</Reference>
<Reference Include="System.Drawing.Primitives">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Drawing.Primitives.dll</HintPath>
</Reference>
<Reference Include="System.Dynamic.Runtime">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Dynamic.Runtime.dll</HintPath>
</Reference>
<Reference Include="System.Globalization.Calendars">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Globalization.Calendars.dll</HintPath>
</Reference>
<Reference Include="System.Globalization">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Globalization.dll</HintPath>
</Reference>
<Reference Include="System.Globalization.Extensions">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Globalization.Extensions.dll</HintPath>
</Reference>
<Reference Include="System.IO.Compression.ZipFile">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.IO.Compression.ZipFile.dll</HintPath>
</Reference>
<Reference Include="System.IO">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.IO.dll</HintPath>
</Reference>
<Reference Include="System.IO.FileSystem">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.IO.FileSystem.dll</HintPath>
</Reference>
<Reference Include="System.IO.FileSystem.DriveInfo">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.IO.FileSystem.DriveInfo.dll</HintPath>
</Reference>
<Reference Include="System.IO.FileSystem.Primitives">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.IO.FileSystem.Primitives.dll</HintPath>
</Reference>
<Reference Include="System.IO.FileSystem.Watcher">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.IO.FileSystem.Watcher.dll</HintPath>
</Reference>
<Reference Include="System.IO.IsolatedStorage">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.IO.IsolatedStorage.dll</HintPath>
</Reference>
<Reference Include="System.IO.MemoryMappedFiles">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.IO.MemoryMappedFiles.dll</HintPath>
</Reference>
<Reference Include="System.IO.Pipes">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.IO.Pipes.dll</HintPath>
</Reference>
<Reference Include="System.IO.UnmanagedMemoryStream">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.IO.UnmanagedMemoryStream.dll</HintPath>
</Reference>
<Reference Include="System.Linq">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Linq.dll</HintPath>
</Reference>
<Reference Include="System.Linq.Expressions">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Linq.Expressions.dll</HintPath>
</Reference>
<Reference Include="System.Linq.Parallel">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Linq.Parallel.dll</HintPath>
</Reference>
<Reference Include="System.Linq.Queryable">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Linq.Queryable.dll</HintPath>
</Reference>
<Reference Include="System.Net.Http.Rtc">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Net.Http.Rtc.dll</HintPath>
</Reference>
<Reference Include="System.Net.NameResolution">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Net.NameResolution.dll</HintPath>
</Reference>
<Reference Include="System.Net.NetworkInformation">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Net.NetworkInformation.dll</HintPath>
</Reference>
<Reference Include="System.Net.Ping">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Net.Ping.dll</HintPath>
</Reference>
<Reference Include="System.Net.Primitives">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Net.Primitives.dll</HintPath>
</Reference>
<Reference Include="System.Net.Requests">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Net.Requests.dll</HintPath>
</Reference>
<Reference Include="System.Net.Security">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Net.Security.dll</HintPath>
</Reference>
<Reference Include="System.Net.Sockets">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Net.Sockets.dll</HintPath>
</Reference>
<Reference Include="System.Net.WebHeaderCollection">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Net.WebHeaderCollection.dll</HintPath>
</Reference>
<Reference Include="System.Net.WebSockets.Client">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Net.WebSockets.Client.dll</HintPath>
</Reference>
<Reference Include="System.Net.WebSockets">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Net.WebSockets.dll</HintPath>
</Reference>
<Reference Include="System.ObjectModel">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.ObjectModel.dll</HintPath>
</Reference>
<Reference Include="System.Reflection">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Reflection.dll</HintPath>
</Reference>
<Reference Include="System.Reflection.Emit">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Reflection.Emit.dll</HintPath>
</Reference>
<Reference Include="System.Reflection.Emit.ILGeneration">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Reflection.Emit.ILGeneration.dll</HintPath>
</Reference>
<Reference Include="System.Reflection.Emit.Lightweight">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Reflection.Emit.Lightweight.dll</HintPath>
</Reference>
<Reference Include="System.Reflection.Extensions">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Reflection.Extensions.dll</HintPath>
</Reference>
<Reference Include="System.Reflection.Primitives">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Reflection.Primitives.dll</HintPath>
</Reference>
<Reference Include="System.Resources.Reader">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Resources.Reader.dll</HintPath>
</Reference>
<Reference Include="System.Resources.ResourceManager">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Resources.ResourceManager.dll</HintPath>
</Reference>
<Reference Include="System.Resources.Writer">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Resources.Writer.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.CompilerServices.VisualC">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Runtime.CompilerServices.VisualC.dll</HintPath>
</Reference>
<Reference Include="System.Runtime">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Runtime.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.Extensions">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Runtime.Extensions.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.Handles">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Runtime.Handles.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.InteropServices">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Runtime.InteropServices.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.InteropServices.RuntimeInformation">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Runtime.InteropServices.RuntimeInformation.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.InteropServices.WindowsRuntime">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Runtime.InteropServices.WindowsRuntime.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.Numerics">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Runtime.Numerics.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.Serialization.Formatters">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Runtime.Serialization.Formatters.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.Serialization.Json">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Runtime.Serialization.Json.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.Serialization.Primitives">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Runtime.Serialization.Primitives.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.Serialization.Xml">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Runtime.Serialization.Xml.dll</HintPath>
</Reference>
<Reference Include="System.Security.Claims">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Security.Claims.dll</HintPath>
</Reference>
<Reference Include="System.Security.Cryptography.Algorithms">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Security.Cryptography.Algorithms.dll</HintPath>
</Reference>
<Reference Include="System.Security.Cryptography.Csp">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Security.Cryptography.Csp.dll</HintPath>
</Reference>
<Reference Include="System.Security.Cryptography.Encoding">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Security.Cryptography.Encoding.dll</HintPath>
</Reference>
<Reference Include="System.Security.Cryptography.Primitives">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Security.Cryptography.Primitives.dll</HintPath>
</Reference>
<Reference Include="System.Security.Cryptography.X509Certificates">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Security.Cryptography.X509Certificates.dll</HintPath>
</Reference>
<Reference Include="System.Security.Principal">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Security.Principal.dll</HintPath>
</Reference>
<Reference Include="System.Security.SecureString">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Security.SecureString.dll</HintPath>
</Reference>
<Reference Include="System.ServiceModel.Duplex">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.ServiceModel.Duplex.dll</HintPath>
</Reference>
<Reference Include="System.ServiceModel.Http">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.ServiceModel.Http.dll</HintPath>
</Reference>
<Reference Include="System.ServiceModel.NetTcp">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.ServiceModel.NetTcp.dll</HintPath>
</Reference>
<Reference Include="System.ServiceModel.Primitives">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.ServiceModel.Primitives.dll</HintPath>
</Reference>
<Reference Include="System.ServiceModel.Security">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.ServiceModel.Security.dll</HintPath>
</Reference>
<Reference Include="System.Text.Encoding">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Text.Encoding.dll</HintPath>
</Reference>
<Reference Include="System.Text.Encoding.Extensions">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Text.Encoding.Extensions.dll</HintPath>
</Reference>
<Reference Include="System.Text.RegularExpressions">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Text.RegularExpressions.dll</HintPath>
</Reference>
<Reference Include="System.Threading">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Threading.dll</HintPath>
</Reference>
<Reference Include="System.Threading.Overlapped">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Threading.Overlapped.dll</HintPath>
</Reference>
<Reference Include="System.Threading.Tasks">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Threading.Tasks.dll</HintPath>
</Reference>
<Reference Include="System.Threading.Tasks.Parallel">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Threading.Tasks.Parallel.dll</HintPath>
</Reference>
<Reference Include="System.Threading.Thread">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Threading.Thread.dll</HintPath>
</Reference>
<Reference Include="System.Threading.ThreadPool">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Threading.ThreadPool.dll</HintPath>
</Reference>
<Reference Include="System.Threading.Timer">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Threading.Timer.dll</HintPath>
</Reference>
<Reference Include="System.ValueTuple">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.ValueTuple.dll</HintPath>
</Reference>
<Reference Include="System.Xml.ReaderWriter">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Xml.ReaderWriter.dll</HintPath>
</Reference>
<Reference Include="System.Xml.XDocument">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Xml.XDocument.dll</HintPath>
</Reference>
<Reference Include="System.Xml.XmlDocument">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Xml.XmlDocument.dll</HintPath>
</Reference>
<Reference Include="System.Xml.XmlSerializer">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Xml.XmlSerializer.dll</HintPath>
</Reference>
<Reference Include="System.Xml.XPath">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Xml.XPath.dll</HintPath>
</Reference>
<Reference Include="System.Xml.XPath.XDocument">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/4.7.1-api/Facades/System.Xml.XPath.XDocument.dll</HintPath>
</Reference>
<Reference Include="UnityScript">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/unityscript/UnityScript.dll</HintPath>
</Reference>
<Reference Include="UnityScript.Lang">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/unityscript/UnityScript.Lang.dll</HintPath>
</Reference>
<Reference Include="Boo.Lang">
<HintPath>C:/Program Files/Unity/2019.2.11f1/Editor/Data/MonoBleedingEdge/lib/mono/unityscript/Boo.Lang.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Content Include=".editorconfig" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
Other similar extension points exist, see Microsoft.Common.targets.
<Target Name="BeforeBuild">
</Target>
<Target Name="AfterBuild">
</Target>
-->
</Project>

View File

@@ -6,9 +6,6 @@ using UnityBuilderAction.Reporting;
using UnityBuilderAction.Versioning;
using UnityEditor;
using UnityEditor.Build.Reporting;
#if UNITY_6000_0_OR_NEWER
using UnityEditor.Build.Profile;
#endif
using UnityEngine;
namespace UnityBuilderAction
@@ -20,9 +17,47 @@ namespace UnityBuilderAction
// Gather values from args
var options = ArgumentsParser.GetValidatedOptions();
// Gather values from project
var scenes = EditorBuildSettings.scenes.Where(scene => scene.enabled).Select(s => s.path).ToArray();
// Get all buildOptions from options
BuildOptions buildOptions = BuildOptions.None;
foreach (string buildOptionString in Enum.GetNames(typeof(BuildOptions))) {
if (options.ContainsKey(buildOptionString)) {
BuildOptions buildOptionEnum = (BuildOptions) Enum.Parse(typeof(BuildOptions), buildOptionString);
buildOptions |= buildOptionEnum;
}
}
#if UNITY_2021_2_OR_NEWER
// Determine subtarget
StandaloneBuildSubtarget buildSubtarget;
if (!options.TryGetValue("standaloneBuildSubtarget", out var subtargetValue) || !Enum.TryParse(subtargetValue, out buildSubtarget)) {
buildSubtarget = default;
}
#endif
// Define BuildPlayer Options
var buildPlayerOptions = new BuildPlayerOptions {
scenes = scenes,
locationPathName = options["customBuildPath"],
target = (BuildTarget) Enum.Parse(typeof(BuildTarget), options["buildTarget"]),
options = buildOptions,
#if UNITY_2021_2_OR_NEWER
subtarget = (int) buildSubtarget
#endif
};
// Set version for this build
VersionApplicator.SetVersion(options["buildVersion"]);
// Apply Android settings
if (buildPlayerOptions.target == BuildTarget.Android)
{
VersionApplicator.SetAndroidVersionCode(options["androidVersionCode"]);
AndroidSettings.Apply(options);
}
// Execute default AddressableAsset content build, if the package is installed.
// Version defines would be the best solution here, but Unity 2018 doesn't support that,
// so we fall back to using reflection instead.
@@ -39,85 +74,10 @@ namespace UnityBuilderAction
}
catch (Exception e)
{
Debug.LogError("Failed to run default addressables build:\n" + e);
Debug.LogError($"Failed to run default addressables build:\n{e}");
}
}
// Get all buildOptions from options
BuildOptions buildOptions = BuildOptions.None;
foreach (string buildOptionString in Enum.GetNames(typeof(BuildOptions))) {
if (options.ContainsKey(buildOptionString)) {
BuildOptions buildOptionEnum = (BuildOptions) Enum.Parse(typeof(BuildOptions), buildOptionString);
buildOptions |= buildOptionEnum;
}
}
// Depending on whether the build is using a build profile, `buildPlayerOptions` will an instance
// of either `UnityEditor.BuildPlayerOptions` or `UnityEditor.BuildPlayerWithProfileOptions`
dynamic buildPlayerOptions;
if (options.TryGetValue("activeBuildProfile", out var buildProfilePath)) {
if (string.IsNullOrEmpty(buildProfilePath)) {
throw new Exception("`-activeBuildProfile` is set but with an empty value; this shouldn't happen");
}
#if UNITY_6000_0_OR_NEWER
// Load build profile from Assets folder
var buildProfile = AssetDatabase.LoadAssetAtPath<BuildProfile>(buildProfilePath)
?? throw new Exception("Build profile file not found at path: " + buildProfilePath);
// no need to set active profile, as already set by `-activeBuildProfile` CLI argument
// BuildProfile.SetActiveBuildProfile(buildProfile);
Debug.Log($"build profile: {buildProfile.name}");
// Define BuildPlayerWithProfileOptions
buildPlayerOptions = new BuildPlayerWithProfileOptions {
buildProfile = buildProfile,
locationPathName = options["customBuildPath"],
options = buildOptions,
};
#else // UNITY_6000_0_OR_NEWER
throw new Exception("Build profiles are not supported by this version of Unity (" + Application.unityVersion +")");
#endif // UNITY_6000_0_OR_NEWER
} else {
#if BUILD_PROFILE_LOADED
throw new Exception("Build profile's define symbol present; shouldn't happen");
#endif // BUILD_PROFILE_LOADED
// Gather values from project
var scenes = EditorBuildSettings.scenes.Where(scene => scene.enabled).Select(s => s.path).ToArray();
#if UNITY_2021_2_OR_NEWER
// Determine subtarget
StandaloneBuildSubtarget buildSubtarget;
if (!options.TryGetValue("standaloneBuildSubtarget", out var subtargetValue) || !Enum.TryParse(subtargetValue, out buildSubtarget)) {
buildSubtarget = default;
}
#endif
BuildTarget buildTarget = (BuildTarget) Enum.Parse(typeof(BuildTarget), options["buildTarget"]);
// Define BuildPlayerOptions
buildPlayerOptions = new BuildPlayerOptions {
scenes = scenes,
locationPathName = options["customBuildPath"],
target = buildTarget,
options = buildOptions,
#if UNITY_2021_2_OR_NEWER
subtarget = (int) buildSubtarget
#endif
};
// Apply Android settings
if (buildTarget == BuildTarget.Android) {
VersionApplicator.SetAndroidVersionCode(options["androidVersionCode"]);
AndroidSettings.Apply(options);
}
}
// Perform build
BuildReport buildReport = BuildPipeline.BuildPlayer(buildPlayerOptions);

View File

@@ -56,17 +56,17 @@ namespace UnityBuilderAction.Input
case "androidStudioProject":
EditorUserBuildSettings.exportAsGoogleAndroidProject = true;
if (buildAppBundle != null)
buildAppBundle.SetValue(null, false, null);
buildAppBundle.SetValue(null, false);
break;
case "androidAppBundle":
EditorUserBuildSettings.exportAsGoogleAndroidProject = false;
if (buildAppBundle != null)
buildAppBundle.SetValue(null, true, null);
buildAppBundle.SetValue(null, true);
break;
case "androidPackage":
EditorUserBuildSettings.exportAsGoogleAndroidProject = false;
if (buildAppBundle != null)
buildAppBundle.SetValue(null, false, null);
buildAppBundle.SetValue(null, false);
break;
}
}
@@ -74,20 +74,7 @@ namespace UnityBuilderAction.Input
string symbolType;
if (options.TryGetValue("androidSymbolType", out symbolType) && !string.IsNullOrEmpty(symbolType))
{
#if UNITY_6000_0_OR_NEWER
switch (symbolType)
{
case "public":
SetDebugSymbols("SymbolTable");
break;
case "debugging":
SetDebugSymbols("Full");
break;
case "none":
SetDebugSymbols("None");
break;
}
#elif UNITY_2021_1_OR_NEWER
#if UNITY_2021_1_OR_NEWER
switch (symbolType)
{
case "public":
@@ -114,37 +101,5 @@ namespace UnityBuilderAction.Input
#endif
}
}
#if UNITY_6000_0_OR_NEWER
private static void SetDebugSymbols(string enumValueName)
{
// UnityEditor.Android.UserBuildSettings and Unity.Android.Types.DebugSymbolLevel are part of the Unity Android module.
// Reflection is used here to ensure the code works even if the module is not installed.
var debugSymbolsType = Type.GetType("UnityEditor.Android.UserBuildSettings+DebugSymbols, UnityEditor.Android.Extensions");
if (debugSymbolsType == null)
{
return;
}
var levelProp = debugSymbolsType.GetProperty("level", BindingFlags.Static | BindingFlags.Public);
if (levelProp == null)
{
return;
}
var enumType = Type.GetType("Unity.Android.Types.DebugSymbolLevel, Unity.Android.Types");
if (enumType == null)
{
return;
}
if (!Enum.TryParse(enumType, enumValueName, false , out var enumValue))
{
return;
}
levelProp.SetValue(null, enumValue);
}
#endif
}
}

View File

@@ -21,19 +21,6 @@ namespace UnityBuilderAction.Input
EditorApplication.Exit(110);
}
#if UNITY_6000_0_OR_NEWER
var buildProfileSupport = true;
#else
var buildProfileSupport = false;
#endif // UNITY_6000_0_OR_NEWER
string buildProfile;
if (buildProfileSupport && validatedOptions.TryGetValue("activeBuildProfile", out buildProfile)) {
if (validatedOptions.ContainsKey("buildTarget")) {
Console.WriteLine("Extra argument -buildTarget");
EditorApplication.Exit(122);
}
} else {
string buildTarget;
if (!validatedOptions.TryGetValue("buildTarget", out buildTarget)) {
Console.WriteLine("Missing argument -buildTarget");
@@ -41,10 +28,9 @@ namespace UnityBuilderAction.Input
}
if (!Enum.IsDefined(typeof(BuildTarget), buildTarget)) {
Console.WriteLine(buildTarget + " is not a defined " + typeof(BuildTarget).Name);
Console.WriteLine($"{buildTarget} is not a defined {nameof(BuildTarget)}");
EditorApplication.Exit(121);
}
}
string customBuildPath;
if (!validatedOptions.TryGetValue("customBuildPath", out customBuildPath)) {
@@ -55,10 +41,10 @@ namespace UnityBuilderAction.Input
const string defaultCustomBuildName = "TestBuild";
string customBuildName;
if (!validatedOptions.TryGetValue("customBuildName", out customBuildName)) {
Console.WriteLine("Missing argument -customBuildName, defaulting to" + defaultCustomBuildName);
Console.WriteLine($"Missing argument -customBuildName, defaulting to {defaultCustomBuildName}.");
validatedOptions.Add("customBuildName", defaultCustomBuildName);
} else if (customBuildName == "") {
Console.WriteLine("Invalid argument -customBuildName, defaulting to" + defaultCustomBuildName);
Console.WriteLine($"Invalid argument -customBuildName, defaulting to {defaultCustomBuildName}.");
validatedOptions.Add("customBuildName", defaultCustomBuildName);
}
@@ -71,11 +57,11 @@ namespace UnityBuilderAction.Input
string[] args = Environment.GetCommandLineArgs();
Console.WriteLine(
EOL +
"###########################" + EOL +
"# Parsing settings #" + EOL +
"###########################" + EOL +
EOL
$"{EOL}" +
$"###########################{EOL}" +
$"# Parsing settings #{EOL}" +
$"###########################{EOL}" +
$"{EOL}"
);
// Extract flags with optional values
@@ -92,7 +78,7 @@ namespace UnityBuilderAction.Input
string displayValue = secret ? "*HIDDEN*" : "\"" + value + "\"";
// Assign
Console.WriteLine("Found flag \"" + flag + "\" with value " + displayValue);
Console.WriteLine($"Found flag \"{flag}\" with value {displayValue}.");
providedArguments.Add(flag, value);
}
}

View File

@@ -1,36 +0,0 @@
using System;
using UnityEngine;
using UnityEditor;
namespace UnityBuilderAction.Reporting
{
[InitializeOnLoad]
static class CompileListener
{
static CompileListener()
{
if (Application.isBatchMode)
{
Application.logMessageReceived += Application_logMessageReceived;
}
}
private static void Application_logMessageReceived(string condition, string stackTrace, LogType type)
{
string prefix = "";
switch (type)
{
case LogType.Error:
prefix = "error";
break;
case LogType.Warning:
prefix = "warning";
break;
case LogType.Exception:
prefix = "error";
break;
}
Console.WriteLine(Environment.NewLine + "::" + prefix + "::" + condition + Environment.NewLine + stackTrace);
}
}
}

View File

@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: fad44373fb7b61a4bb584e2675795aca
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -11,16 +11,16 @@ namespace UnityBuilderAction.Reporting
public static void ReportSummary(BuildSummary summary)
{
Console.WriteLine(
EOL +
"###########################" + EOL +
"# Build results #" + EOL +
"###########################" + EOL +
EOL +
"Duration: " + summary.totalTime.ToString() + EOL +
"Warnings: " + summary.totalWarnings.ToString() + EOL +
"Errors: " + summary.totalErrors.ToString() + EOL +
"Size: " + summary.totalSize.ToString() + " bytes" + EOL +
EOL
$"{EOL}" +
$"###########################{EOL}" +
$"# Build results #{EOL}" +
$"###########################{EOL}" +
$"{EOL}" +
$"Duration: {summary.totalTime.ToString()}{EOL}" +
$"Warnings: {summary.totalWarnings.ToString()}{EOL}" +
$"Errors: {summary.totalErrors.ToString()}{EOL}" +
$"Size: {summary.totalSize.ToString()} bytes{EOL}" +
$"{EOL}"
);
}

View File

@@ -21,11 +21,11 @@ namespace UnityBuilderAction.Versioning
version = GetSemanticCommitVersion();
Console.WriteLine("Repository has a valid version tag.");
} else {
version = "0.0." + GetTotalNumberOfCommits();
version = $"0.0.{GetTotalNumberOfCommits()}";
Console.WriteLine("Repository does not have tags to base the version on.");
}
Console.WriteLine("Version is " + version);
Console.WriteLine($"Version is {version}");
return version;
}

View File

@@ -0,0 +1,10 @@
{
"m_SettingKeys": [
"VR Device Disabled",
"VR Device User Alert"
],
"m_SettingValues": [
"False",
"False"
]
}

View File

@@ -0,0 +1,20 @@

Microsoft Visual Studio Solution File, Format Version 11.00
# Visual Studio 2010
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Assembly-CSharp-Editor", "Assembly-CSharp-Editor.csproj", "{B7F8614B-1EC2-9D3A-DA1C-4D279A867D74}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B7F8614B-1EC2-9D3A-DA1C-4D279A867D74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B7F8614B-1EC2-9D3A-DA1C-4D279A867D74}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B7F8614B-1EC2-9D3A-DA1C-4D279A867D74}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B7F8614B-1EC2-9D3A-DA1C-4D279A867D74}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=Untracked/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Versioning/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

177124
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

15815
dist/licenses.txt generated vendored

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,28 @@
#!/usr/bin/env bash
#
# Perform Activation
# Create directories for license activation
#
if [ "$SKIP_ACTIVATION" != "true" ]; then
UNITY_LICENSE_PATH="/Library/Application Support/Unity"
sudo mkdir /Library/Application\ Support/Unity
sudo chmod -R 777 /Library/Application\ Support/Unity
if [ ! -d "$UNITY_LICENSE_PATH" ]; then
echo "Creating Unity License Directory"
sudo mkdir -p "$UNITY_LICENSE_PATH"
sudo chmod -R 777 "$UNITY_LICENSE_PATH"
fi;
ACTIVATE_LICENSE_PATH="$ACTION_FOLDER/BlankProject"
mkdir -p "$ACTIVATE_LICENSE_PATH"
source $ACTION_FOLDER/platforms/mac/steps/activate.sh
else
echo "Skipping activation"
fi
ACTIVATE_LICENSE_PATH="$ACTION_FOLDER/BlankProject"
mkdir -p "$ACTIVATE_LICENSE_PATH"
#
# Run Build
# Run steps
#
source $ACTION_FOLDER/platforms/mac/steps/activate.sh
source $ACTION_FOLDER/platforms/mac/steps/build.sh
source $ACTION_FOLDER/platforms/mac/steps/return_license.sh
#
# License Cleanup
# Remove license activation directory
#
if [ "$SKIP_ACTIVATION" != "true" ]; then
source $ACTION_FOLDER/platforms/mac/steps/return_license.sh
rm -r "$ACTIVATE_LICENSE_PATH"
fi
sudo rm -r /Library/Application\ Support/Unity
rm -r "$ACTIVATE_LICENSE_PATH"
#
# Instructions for debugging
@@ -49,7 +37,7 @@ echo ""
echo "Please note that the exit code is not very descriptive."
echo "Most likely it will not help you solve the issue."
echo ""
echo "To find the reason for failure: please search for errors in the log above and check for annotations in the summary view."
echo "To find the reason for failure: please search for errors in the log above."
echo ""
fi;

View File

@@ -4,69 +4,21 @@
echo "Changing to \"$ACTIVATE_LICENSE_PATH\" directory."
pushd "$ACTIVATE_LICENSE_PATH"
if [[ -n "$UNITY_SERIAL" && -n "$UNITY_EMAIL" && -n "$UNITY_PASSWORD" ]]; then
#
# SERIAL LICENSE MODE
#
# This will activate unity, using the serial activation process.
#
echo "Requesting activation"
echo "Requesting activation"
# Activate license
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity \
-logFile - \
-batchmode \
-nographics \
-quit \
-serial "$UNITY_SERIAL" \
-username "$UNITY_EMAIL" \
-password "$UNITY_PASSWORD" \
-projectPath "$ACTIVATE_LICENSE_PATH"
# Activate license
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity \
-logFile - \
-batchmode \
-nographics \
-quit \
-serial "$UNITY_SERIAL" \
-username "$UNITY_EMAIL" \
-password "$UNITY_PASSWORD" \
-projectPath "$ACTIVATE_LICENSE_PATH"
# Store the exit code from the verify command
UNITY_EXIT_CODE=$?
elif [[ -n "$UNITY_LICENSING_SERVER" ]]; then
#
# Custom Unity License Server
#
echo "Adding licensing server config"
mkdir -p "$UNITY_LICENSE_PATH/config/"
cp "$ACTION_FOLDER/unity-config/services-config.json" "$UNITY_LICENSE_PATH/config/services-config.json"
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/Frameworks/UnityLicensingClient.app/Contents/MacOS/Unity.Licensing.Client \
--acquire-floating > license.txt
# Store the exit code from the verify command
UNITY_EXIT_CODE=$?
if [ $UNITY_EXIT_CODE -eq 0 ]; then
PARSEDFILE=$(grep -oE '\"[^"]*\"' < license.txt | tr -d '"')
export FLOATING_LICENSE
FLOATING_LICENSE=$(sed -n 2p <<< "$PARSEDFILE")
FLOATING_LICENSE_TIMEOUT=$(sed -n 4p <<< "$PARSEDFILE")
echo "Acquired floating license: \"$FLOATING_LICENSE\" with timeout $FLOATING_LICENSE_TIMEOUT"
fi
else
#
# NO LICENSE ACTIVATION STRATEGY MATCHED
#
# This will exit since no activation strategies could be matched.
#
echo "License activation strategy could not be determined."
echo ""
echo "Visit https://game.ci/docs/github/activation for more"
echo "details on how to set up one of the possible activation strategies."
echo "::error ::No valid license activation strategy could be determined. Make sure to provide UNITY_EMAIL, UNITY_PASSWORD, and either a UNITY_SERIAL \
or UNITY_LICENSE. Otherwise please use UNITY_LICENSING_SERVER. See more info at https://game.ci/docs/github/activation"
# Immediately exit as no UNITY_EXIT_CODE can be derived.
exit 1;
fi
# Store the exit code from the verify command
UNITY_EXIT_CODE=$?
#
# Display information about the result
@@ -78,7 +30,6 @@ else
# Activation failed so exit with the code from the license verification step
echo "Unclassified error occured while trying to activate license."
echo "Exit code was: $UNITY_EXIT_CODE"
echo "::error ::There was an error while trying to activate the Unity license."
exit $UNITY_EXIT_CODE
fi

View File

@@ -19,23 +19,6 @@ echo "Using build name \"$BUILD_NAME\"."
echo "Using build target \"$BUILD_TARGET\"."
#
# Display the build profile
#
if [ -z "$BUILD_PROFILE" ]; then
# User has not provided a build profile
#
echo "Doing a default \"$BUILD_TARGET\" platform build."
#
else
# User has provided a path to a build profile `.asset` file
#
echo "Using build profile \"$BUILD_PROFILE\" relative to \"$UNITY_PROJECT_PATH\"."
#
fi
#
# Display build path and file
#
@@ -146,16 +129,16 @@ echo ""
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity \
-logFile - \
$( [ "${MANUAL_EXIT}" == "true" ] || echo "-quit" ) \
-quit \
-batchmode \
$( [ "${ENABLE_GPU}" == "true" ] || echo "-nographics" ) \
-nographics \
-username "$UNITY_EMAIL" \
-password "$UNITY_PASSWORD" \
-customBuildName "$BUILD_NAME" \
-projectPath "$UNITY_PROJECT_PATH" \
$( [ -z "$BUILD_PROFILE" ] && echo "-buildTarget $BUILD_TARGET") \
-buildTarget "$BUILD_TARGET" \
-customBuildTarget "$BUILD_TARGET" \
-customBuildPath "$CUSTOM_BUILD_PATH" \
-customBuildProfile "$BUILD_PROFILE" \
${BUILD_PROFILE:+-activeBuildProfile} ${BUILD_PROFILE:+"$BUILD_PROFILE"} \
-executeMethod "$BUILD_METHOD" \
-buildVersion "$VERSION" \
-androidVersionCode "$ANDROID_VERSION_CODE" \

View File

@@ -4,29 +4,15 @@
echo "Changing to \"$ACTIVATE_LICENSE_PATH\" directory."
pushd "$ACTIVATE_LICENSE_PATH"
if [[ -n "$UNITY_LICENSING_SERVER" ]]; then
#
# Return any floating license used.
#
echo "Returning floating license: \"$FLOATING_LICENSE\""
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/Frameworks/UnityLicensingClient.app/Contents/MacOS/Unity.Licensing.Client \
--return-floating "$FLOATING_LICENSE"
elif [[ -n "$UNITY_SERIAL" ]]; then
#
# SERIAL LICENSE MODE
#
# This will return the license that is currently in use.
#
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity \
-logFile - \
-batchmode \
-nographics \
-quit \
-username "$UNITY_EMAIL" \
-password "$UNITY_PASSWORD" \
-returnlicense \
-projectPath "$ACTIVATE_LICENSE_PATH"
fi
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity \
-logFile - \
-batchmode \
-nographics \
-quit \
-username "$UNITY_EMAIL" \
-password "$UNITY_PASSWORD" \
-returnlicense \
-projectPath "$ACTIVATE_LICENSE_PATH"
# Return to previous working directory
popd

View File

@@ -1,83 +1,46 @@
#!/usr/bin/env bash
# Ensure machine ID is randomized for personal license activation
if [[ "$UNITY_SERIAL" = F* ]]; then
echo "Randomizing machine ID for personal license activation"
dbus-uuidgen > /etc/machine-id && mkdir -p /var/lib/dbus/ && ln -sf /etc/machine-id /var/lib/dbus/machine-id
fi
#
# Prepare Android SDK, if needed
# We do this here to ensure it has root permissions
# Create directory for license activation
#
fullProjectPath="$GITHUB_WORKSPACE/$PROJECT_PATH"
ACTIVATE_LICENSE_PATH="$GITHUB_WORKSPACE/_activate-license~"
mkdir -p "$ACTIVATE_LICENSE_PATH"
if [[ "$BUILD_TARGET" == "Android" ]]; then
export JAVA_HOME="$(awk -F'=' '/JAVA_HOME=/{print $2}' /usr/bin/unity-editor.d/*)"
ANDROID_HOME_DIRECTORY="$(awk -F'=' '/ANDROID_HOME=/{print $2}' /usr/bin/unity-editor.d/*)"
SDKMANAGER=$(find $ANDROID_HOME_DIRECTORY/cmdline-tools -name sdkmanager)
if [ -z "${SDKMANAGER}" ]
then
SDKMANAGER=$(find $ANDROID_HOME_DIRECTORY/tools/bin -name sdkmanager)
if [ -z "${SDKMANAGER}" ]
then
echo "No sdkmanager found"
exit 1
fi
fi
#
# Run steps
#
source /steps/set_extra_git_configs.sh
source /steps/set_gitcredential.sh
source /steps/activate.sh
source /steps/build.sh
source /steps/return_license.sh
if [[ -n "$ANDROID_SDK_MANAGER_PARAMETERS" ]]; then
echo "Updating Android SDK with parameters: $ANDROID_SDK_MANAGER_PARAMETERS"
$SDKMANAGER "$ANDROID_SDK_MANAGER_PARAMETERS"
else
echo "Updating Android SDK with auto detected target API version"
# Read the line containing AndroidTargetSdkVersion from the file
targetAPILine=$(grep 'AndroidTargetSdkVersion' "$fullProjectPath/ProjectSettings/ProjectSettings.asset")
#
# Remove license activation directory
#
# Extract the number after the semicolon
targetAPI=$(echo "$targetAPILine" | cut -d':' -f2 | tr -d '[:space:]')
rm -r "$ACTIVATE_LICENSE_PATH"
$SDKMANAGER "platforms;android-$targetAPI"
fi
#
# Instructions for debugging
#
echo "Updated Android SDK."
else
echo "Not updating Android SDK."
fi
if [[ $BUILD_EXIT_CODE -gt 0 ]]; then
echo ""
echo "###########################"
echo "# Failure #"
echo "###########################"
echo ""
echo "Please note that the exit code is not very descriptive."
echo "Most likely it will not help you solve the issue."
echo ""
echo "To find the reason for failure: please search for errors in the log above."
echo ""
fi;
if [[ "$RUN_AS_HOST_USER" == "true" ]]; then
echo "Running as host user"
#
# Exit with code from the build step.
#
# Stop on error if we can't set up the user
set -e
# Get host user/group info so we create files with the correct ownership
USERNAME=$(stat -c '%U' "$fullProjectPath")
USERID=$(stat -c '%u' "$fullProjectPath")
GROUPNAME=$(stat -c '%G' "$fullProjectPath")
GROUPID=$(stat -c '%g' "$fullProjectPath")
groupadd -g $GROUPID $GROUPNAME
useradd -u $USERID -g $GROUPID $USERNAME
usermod -aG $GROUPNAME $USERNAME
mkdir -p "/home/$USERNAME"
chown $USERNAME:$GROUPNAME "/home/$USERNAME"
# Normally need root permissions to access when using su
chmod 777 /dev/stdout
chmod 777 /dev/stderr
# 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
su $USERNAME -c "$SHELL -c 'source /steps/runsteps.sh'"
else
echo "Running as root"
# Run as root
source /steps/runsteps.sh
fi
exit $?
exit $BUILD_EXIT_CODE

View File

@@ -1,65 +1,78 @@
#!/usr/bin/env bash
# if blankproject folder doesn't exist create it
if [ ! -d "/BlankProject" ]; then
mkdir /BlankProject
fi
# if blankproject folder doesn't exist create it
if [ ! -d "/BlankProject/Assets" ]; then
mkdir /BlankProject/Assets
fi
# Run in ACTIVATE_LICENSE_PATH directory
echo "Changing to \"$ACTIVATE_LICENSE_PATH\" directory."
pushd "$ACTIVATE_LICENSE_PATH"
if [[ -n "$UNITY_SERIAL" && -n "$UNITY_EMAIL" && -n "$UNITY_PASSWORD" ]]; then
if [[ -n "$UNITY_LICENSE" ]] || [[ -n "$UNITY_LICENSE_FILE" ]]; then
#
# SERIAL LICENSE MODE
# PERSONAL LICENSE MODE
#
# This will activate unity, using the serial activation process.
# This will activate Unity, using a license file
#
echo "Requesting activation"
# Note that this is the ONLY WAY for PERSONAL LICENSES in 2020.
# * See for more details: https://gitlab.com/gableroux/unity3d-gitlab-ci-example/issues/5#note_72815478
#
# The license file can be acquired using `webbertakken/request-manual-activation-file` action.
echo "Requesting activation (personal license)"
# Loop the unity-editor call until the license is activated with exponential backoff and a maximum of 5 retries
retry_count=0
# Set the license file path
FILE_PATH=UnityLicenseFile.ulf
# Initialize delay to 15 seconds
delay=15
if [[ -n "$UNITY_LICENSE" ]]; then
# Copy license file from Github variables
echo "$UNITY_LICENSE" | tr -d '\r' > $FILE_PATH
elif [[ -n "$UNITY_LICENSE_FILE" ]]; then
# Copy license file from file system
cat "$UNITY_LICENSE_FILE" | tr -d '\r' > $FILE_PATH
fi
# Loop until UNITY_EXIT_CODE is 0 or retry count reaches 5
while [[ $retry_count -lt 5 ]]
do
# Activate license
unity-editor \
# Activate license
ACTIVATION_OUTPUT=$(unity-editor \
-logFile /dev/stdout \
-quit \
-serial "$UNITY_SERIAL" \
-username "$UNITY_EMAIL" \
-password "$UNITY_PASSWORD" \
-projectPath "/BlankProject"
-manualLicenseFile $FILE_PATH)
# Store the exit code from the verify command
UNITY_EXIT_CODE=$?
# Store the exit code from the verify command
UNITY_EXIT_CODE=$?
# Check if UNITY_EXIT_CODE is 0
if [[ $UNITY_EXIT_CODE -eq 0 ]]
then
echo "Activation successful"
break
else
# Increment retry count
((retry_count++))
# The exit code for personal activation is always 1;
# Determine whether activation was successful.
#
# Successful output should include the following:
#
# "LICENSE SYSTEM [2020120 18:51:20] Next license update check is after 2019-11-25T18:23:38"
#
ACTIVATION_SUCCESSFUL=$(echo $ACTIVATION_OUTPUT | grep 'Next license update check is after' | wc -l)
echo "::warning ::Activation failed, attempting retry #$retry_count"
echo "Activation failed, retrying in $delay seconds..."
sleep $delay
# Set exit code to 0 if activation was successful
if [[ $ACTIVATION_SUCCESSFUL -eq 1 ]]; then
UNITY_EXIT_CODE=0
fi;
# Double the delay for the next iteration
delay=$((delay * 2))
fi
done
# Remove license file
rm -f $FILE_PATH
if [[ $retry_count -eq 5 ]]
then
echo "Activation failed after 5 retries"
fi
elif [[ -n "$UNITY_SERIAL" && -n "$UNITY_EMAIL" && -n "$UNITY_PASSWORD" ]]; then
#
# PROFESSIONAL (SERIAL) LICENSE MODE
#
# This will activate unity, using the activating process.
#
# Note: This is the preferred way for PROFESSIONAL LICENSES.
#
echo "Requesting activation (professional license)"
# Activate license
unity-editor \
-logFile /dev/stdout \
-quit \
-serial "$UNITY_SERIAL" \
-username "$UNITY_EMAIL" \
-password "$UNITY_PASSWORD"
# Store the exit code from the verify command
UNITY_EXIT_CODE=$?
elif [[ -n "$UNITY_LICENSING_SERVER" ]]; then
#
@@ -68,18 +81,14 @@ elif [[ -n "$UNITY_LICENSING_SERVER" ]]; then
echo "Adding licensing server config"
/opt/unity/Editor/Data/Resources/Licensing/Client/Unity.Licensing.Client --acquire-floating > license.txt #is this accessible in a env variable?
PARSEDFILE=$(grep -oP '\".*?\"' < license.txt | tr -d '"')
export FLOATING_LICENSE
FLOATING_LICENSE=$(sed -n 2p <<< "$PARSEDFILE")
FLOATING_LICENSE_TIMEOUT=$(sed -n 4p <<< "$PARSEDFILE")
echo "Acquired floating license: \"$FLOATING_LICENSE\" with timeout $FLOATING_LICENSE_TIMEOUT"
# Store the exit code from the verify command
UNITY_EXIT_CODE=$?
if [ $UNITY_EXIT_CODE -eq 0 ]; then
PARSEDFILE=$(grep -oP '\".*?\"' < license.txt | tr -d '"')
export FLOATING_LICENSE
FLOATING_LICENSE=$(sed -n 2p <<< "$PARSEDFILE")
FLOATING_LICENSE_TIMEOUT=$(sed -n 4p <<< "$PARSEDFILE")
echo "Acquired floating license: \"$FLOATING_LICENSE\" with timeout $FLOATING_LICENSE_TIMEOUT"
fi
else
#
# NO LICENSE ACTIVATION STRATEGY MATCHED
@@ -88,13 +97,10 @@ else
#
echo "License activation strategy could not be determined."
echo ""
echo "Visit https://game.ci/docs/github/activation for more"
echo "Visit https://game.ci/docs/github/getting-started for more"
echo "details on how to set up one of the possible activation strategies."
echo "::error ::No valid license activation strategy could be determined. Make sure to provide UNITY_EMAIL, UNITY_PASSWORD, and either a UNITY_SERIAL \
or UNITY_LICENSE. Otherwise please use UNITY_LICENSING_SERVER. See more info at https://game.ci/docs/github/activation"
# Immediately exit as no UNITY_EXIT_CODE can be derived.
# Immediately exit as no UNITY_EXIT_CODE can be derrived.
exit 1;
fi
@@ -109,6 +115,8 @@ else
# Activation failed so exit with the code from the license verification step
echo "Unclassified error occured while trying to activate license."
echo "Exit code was: $UNITY_EXIT_CODE"
echo "::error ::There was an error while trying to activate the Unity license."
exit $UNITY_EXIT_CODE
fi
# Return to previous working directory
popd

View File

@@ -19,22 +19,6 @@ echo "Using build name \"$BUILD_NAME\"."
echo "Using build target \"$BUILD_TARGET\"."
#
# Display the build profile
#
if [ -z "$BUILD_PROFILE" ]; then
# User has not provided a build profile
#
echo "Doing a default \"$BUILD_TARGET\" platform build."
#
else
# User has provided a path to a build profile `.asset` file
#
echo "Using build profile \"$BUILD_PROFILE\" relative to \"$UNITY_PROJECT_PATH\"."
#
fi
#
# Display build path and file
#
@@ -78,6 +62,19 @@ else
#
fi
#
# Prepare Android SDK, if needed
#
if [[ "$BUILD_TARGET" == "Android" && -n "$ANDROID_SDK_MANAGER_PARAMETERS" ]]; then
echo "Updating Android SDK with parameters: $ANDROID_SDK_MANAGER_PARAMETERS"
export JAVA_HOME="$(awk -F'=' '/JAVA_HOME=/{print $2}' /usr/bin/unity-editor.d/*)"
"$(awk -F'=' '/ANDROID_HOME=/{print $2}' /usr/bin/unity-editor.d/*)/cmdline-tools/6.0/bin/sdkmanager" "$ANDROID_SDK_MANAGER_PARAMETERS"
echo "Updated Android SDK."
else
echo "Not updating Android SDK."
fi
#
# Pre-build debug information
#
@@ -125,11 +122,9 @@ unity-editor \
$( [ "${MANUAL_EXIT}" == "true" ] || echo "-quit" ) \
-customBuildName "$BUILD_NAME" \
-projectPath "$UNITY_PROJECT_PATH" \
$( [ -z "$BUILD_PROFILE" ] && echo "-buildTarget $BUILD_TARGET" ) \
-buildTarget "$BUILD_TARGET" \
-customBuildTarget "$BUILD_TARGET" \
-customBuildPath "$CUSTOM_BUILD_PATH" \
-customBuildProfile "$BUILD_PROFILE" \
${BUILD_PROFILE:+-activeBuildProfile} ${BUILD_PROFILE:+"$BUILD_PROFILE"} \
-executeMethod "$BUILD_METHOD" \
-buildVersion "$VERSION" \
-androidVersionCode "$ANDROID_VERSION_CODE" \

View File

@@ -1,6 +1,11 @@
#!/usr/bin/env bash
if [[ -n "$UNITY_LICENSING_SERVER" ]]; then
# Run in ACTIVATE_LICENSE_PATH directory
echo "Changing to \"$ACTIVATE_LICENSE_PATH\" directory."
pushd "$ACTIVATE_LICENSE_PATH"
if [[ -n "$UNITY_LICENSING_SERVER" ]]; then #
#
# Return any floating license used.
#
@@ -8,15 +13,15 @@ if [[ -n "$UNITY_LICENSING_SERVER" ]]; then
/opt/unity/Editor/Data/Resources/Licensing/Client/Unity.Licensing.Client --return-floating "$FLOATING_LICENSE"
elif [[ -n "$UNITY_SERIAL" ]]; then
#
# SERIAL LICENSE MODE
# PROFESSIONAL (SERIAL) LICENSE MODE
#
# This will return the license that is currently in use.
#
unity-editor \
-logFile /dev/stdout \
-quit \
-returnlicense \
-username "$UNITY_EMAIL" \
-password "$UNITY_PASSWORD" \
-projectPath "/BlankProject"
-returnlicense
fi
# Return to previous working directory
popd

View File

@@ -1,48 +0,0 @@
#!/usr/bin/env bash
#
# Run steps
#
source /steps/set_extra_git_configs.sh
source /steps/set_gitcredential.sh
if [ "$SKIP_ACTIVATION" != "true" ]; then
source /steps/activate.sh
# If we didn't activate successfully, exit with the exit code from the activation step.
if [[ $UNITY_EXIT_CODE -ne 0 ]]; then
exit $UNITY_EXIT_CODE
fi
else
echo "Skipping activation"
fi
source /steps/build.sh
if [ "$SKIP_ACTIVATION" != "true" ]; then
source /steps/return_license.sh
fi
#
# Instructions for debugging
#
if [[ $BUILD_EXIT_CODE -gt 0 ]]; then
echo ""
echo "###########################"
echo "# Failure #"
echo "###########################"
echo ""
echo "Please note that the exit code is not very descriptive."
echo "Most likely it will not help you solve the issue."
echo ""
echo "To find the reason for failure: please search for errors in the log above and check for annotations in the summary view."
echo ""
fi;
#
# Exit with code from the build step.
#
# Exiting su
exit $BUILD_EXIT_CODE

View File

@@ -1,93 +1,7 @@
# Activates Unity
Write-Output ""
Write-Output "###########################"
Write-Output "# Activating #"
Write-Output "###########################"
Write-Output ""
if ( ($null -ne ${env:UNITY_SERIAL}) -and ($null -ne ${env:UNITY_EMAIL}) -and ($null -ne ${env:UNITY_PASSWORD}) )
{
#
# SERIAL LICENSE MODE
#
# This will activate unity, using the serial activation process.
#
Write-Output "Requesting activation"
$ACTIVATION_OUTPUT = Start-Process -FilePath "$Env:UNITY_PATH/Editor/Unity.exe" `
-NoNewWindow `
-PassThru `
-ArgumentList "-batchmode `
-quit `
-nographics `
-username $Env:UNITY_EMAIL `
-password $Env:UNITY_PASSWORD `
-serial $Env:UNITY_SERIAL `
-projectPath c:/BlankProject `
-logfile -"
# Cache the handle so exit code works properly
# https://stackoverflow.com/questions/10262231/obtaining-exitcode-using-start-process-and-waitforexit-instead-of-wait
$unityHandle = $ACTIVATION_OUTPUT.Handle
while ($true) {
if ($ACTIVATION_OUTPUT.HasExited) {
$ACTIVATION_EXIT_CODE = $ACTIVATION_OUTPUT.ExitCode
# Display results
if ($ACTIVATION_EXIT_CODE -eq 0)
{
Write-Output "Activation Succeeded"
} else
{
Write-Output "Activation failed, with exit code $ACTIVATION_EXIT_CODE"
}
break
}
Start-Sleep -Seconds 3
}
}
elseif( ($null -ne ${env:UNITY_LICENSING_SERVER}))
{
#
# Custom Unity License Server
#
Write-Output "Adding licensing server config"
$ACTIVATION_OUTPUT = Start-Process -FilePath "$Env:UNITY_PATH\Editor\Data\Resources\Licensing\Client\Unity.Licensing.Client.exe" `
-ArgumentList "--acquire-floating" `
-NoNewWindow `
-PassThru `
-Wait `
-RedirectStandardOutput "license.txt"
$PARSEDFILE = (Get-Content "license.txt" | Select-String -AllMatches -Pattern '\".*?\"' | ForEach-Object { $_.Matches.Value }) -replace '"'
$env:FLOATING_LICENSE = $PARSEDFILE[1]
$FLOATING_LICENSE_TIMEOUT = $PARSEDFILE[3]
Write-Output "Acquired floating license: ""$env:FLOATING_LICENSE"" with timeout $FLOATING_LICENSE_TIMEOUT"
# Store the exit code from the verify command
$ACTIVATION_EXIT_CODE = $ACTIVATION_OUTPUT.ExitCode
}
else
{
#
# NO LICENSE ACTIVATION STRATEGY MATCHED
#
# This will exit since no activation strategies could be matched.
#
Write-Output "License activation strategy could not be determined."
Write-Output ""
Write-Output "Visit https://game.ci/docs/github/activation for more"
Write-Output "details on how to set up one of the possible activation strategies."
Write-Output "::error ::No valid license activation strategy could be determined. Make sure to provide UNITY_EMAIL, UNITY_PASSWORD, and either a UNITY_SERIAL \
or UNITY_LICENSE. See more info at https://game.ci/docs/github/activation"
$ACTIVATION_EXIT_CODE = 1;
}
& "C:\Program Files\Unity\Hub\Editor\$Env:UNITY_VERSION\Editor\Unity.exe" -batchmode -quit -nographics `
-username $Env:UNITY_EMAIL `
-password $Env:UNITY_PASSWORD `
-serial $Env:UNITY_SERIAL `
-projectPath "c:/BlankProject" `
-logfile | Out-Host

View File

@@ -16,25 +16,6 @@ Write-Output "$('Using build name "')$($Env:BUILD_NAME)$('".')"
Write-Output "$('Using build target "')$($Env:BUILD_TARGET)$('".')"
#
# Display the build profile
#
if ($Env:BUILD_PROFILE)
{
# User has provided a path to a build profile `.asset` file
#
Write-Output "$('Using build profile "')$($Env:BUILD_PROFILE)$('" relative to "')$($Env:UNITY_PROJECT_PATH)$('".')"
#
}
else
{
# User has not provided a build profile
#
Write-Output "$('Doing a default "')$($Env:BUILD_TARGET)$('" platform build.')"
#
}
#
# Display build path and file
#
@@ -85,26 +66,6 @@ else
Get-ChildItem -Path $Env:UNITY_PROJECT_PATH\Assets\Editor -Recurse
}
if ( "$Env:BUILD_TARGET" -eq "Android" -and -not ([string]::IsNullOrEmpty("$Env:ANDROID_KEYSTORE_BASE64")) )
{
Write-Output "Creating Android keystore."
# Write to consistent location as Windows Unity seems to have issues with pwd and can't find the keystore
$keystorePath = "C:/android.keystore"
[System.IO.File]::WriteAllBytes($keystorePath, [System.Convert]::FromBase64String($Env:ANDROID_KEYSTORE_BASE64))
# Ensure the project settings are pointed at the correct path
$unitySettingsPath = "$Env:UNITY_PROJECT_PATH\ProjectSettings\ProjectSettings.asset"
$fileContent = Get-Content -Path "$unitySettingsPath"
$fileContent = $fileContent -replace "AndroidKeystoreName:\s+.*", "AndroidKeystoreName: $keystorePath"
$fileContent | Set-Content -Path "$unitySettingsPath"
Write-Output "Created Android keystore."
}
else {
Write-Output "Not creating Android keystore."
}
#
# Pre-build debug information
#
@@ -148,84 +109,51 @@ Write-Output "# Building project #"
Write-Output "###########################"
Write-Output ""
$unityGraphics = "-nographics"
if ($LLVMPIPE_INSTALLED -eq "true")
{
$unityGraphics = "-force-opengl"
}
# If $Env:CUSTOM_PARAMETERS contains spaces and is passed directly on the command line to Unity, powershell will wrap it
# in double quotes. To avoid this, parse $Env:CUSTOM_PARAMETERS into an array, while respecting any quotations within the string.
$_, $customParametersArray = Invoke-Expression('Write-Output -- "" ' + $Env:CUSTOM_PARAMETERS)
$unityArgs = @(
"-quit",
"-batchmode",
$unityGraphics,
"-silent-crashes",
"-customBuildName", "`"$Env:BUILD_NAME`"",
"-projectPath", "`"$Env:UNITY_PROJECT_PATH`"",
"-executeMethod", "`"$Env:BUILD_METHOD`"",
"-customBuildTarget", "`"$Env:BUILD_TARGET`"",
"-customBuildPath", "`"$Env:CUSTOM_BUILD_PATH`"",
"-customBuildProfile", "`"$Env:BUILD_PROFILE`"",
"-buildVersion", "`"$Env:VERSION`"",
"-androidVersionCode", "`"$Env:ANDROID_VERSION_CODE`"",
"-androidKeystorePass", "`"$Env:ANDROID_KEYSTORE_PASS`"",
"-androidKeyaliasName", "`"$Env:ANDROID_KEYALIAS_NAME`"",
"-androidKeyaliasPass", "`"$Env:ANDROID_KEYALIAS_PASS`"",
"-androidTargetSdkVersion", "`"$Env:ANDROID_TARGET_SDK_VERSION`"",
"-androidExportType", "`"$Env:ANDROID_EXPORT_TYPE`"",
"-androidSymbolType", "`"$Env:ANDROID_SYMBOL_TYPE`"",
"-logfile", "-"
) + $customParametersArray
if (-not $Env:BUILD_PROFILE) {
$unityArgs += @("-buildTarget", "`"$Env:BUILD_TARGET`"")
}
if ($Env:BUILD_PROFILE) {
$unityArgs += @("-activeBuildProfile", "`"$Env:BUILD_PROFILE`"")
& "C:\Program Files\Unity\Hub\Editor\$Env:UNITY_VERSION\Editor\Unity.exe" -quit -batchmode -nographics `
-projectPath $Env:UNITY_PROJECT_PATH `
-executeMethod $Env:BUILD_METHOD `
-buildTarget $Env:BUILD_TARGET `
-customBuildTarget $Env:BUILD_TARGET `
-customBuildPath $Env:CUSTOM_BUILD_PATH `
-buildVersion $Env:VERSION `
-androidVersionCode $Env:ANDROID_VERSION_CODE `
-androidKeystoreName $Env:ANDROID_KEYSTORE_NAME `
-androidKeystorePass $Env:ANDROID_KEYSTORE_PASS `
-androidKeyaliasName $Env:ANDROID_KEYALIAS_NAME `
-androidKeyaliasPass $Env:ANDROID_KEYALIAS_PASS `
-androidTargetSdkVersion $Env:ANDROID_TARGET_SDK_VERSION `
-androidExportType $Env:ANDROID_EXPORT_TYPE `
-androidSymbolType $Env:ANDROID_SYMBOL_TYPE `
$customParametersArray `
-logfile | Out-Host
# Catch exit code
$Env:BUILD_EXIT_CODE=$LastExitCode
# Display results
if ($Env:BUILD_EXIT_CODE -eq 0)
{
Write-Output "Build Succeeded!"
} else
{
Write-Output "$('Build failed, with exit code ')$($Env:BUILD_EXIT_CODE)$('"')"
}
# Remove null items as that will fail the Start-Process call
$unityArgs = $unityArgs | Where-Object { $_ -ne $null }
# TODO: Determine if we need to set permissions on any files
$unityProcess = Start-Process -FilePath "$Env:UNITY_PATH/Editor/Unity.exe" `
-ArgumentList $unityArgs `
-PassThru `
-NoNewWindow
#
# Results
#
# Cache the handle so exit code works properly
# https://stackoverflow.com/questions/10262231/obtaining-exitcode-using-start-process-and-waitforexit-instead-of-wait
$unityHandle = $unityProcess.Handle
Write-Output ""
Write-Output "###########################"
Write-Output "# Build output #"
Write-Output "###########################"
Write-Output ""
while ($true) {
if ($unityProcess.HasExited) {
Start-Sleep -Seconds 3
Get-Process
$BUILD_EXIT_CODE = $unityProcess.ExitCode
# Display results
if ($BUILD_EXIT_CODE -eq 0)
{
Write-Output "Build Succeeded!!"
} else
{
Write-Output "Build failed, with exit code $BUILD_EXIT_CODE"
}
Write-Output ""
Write-Output "###########################"
Write-Output "# Build output #"
Write-Output "###########################"
Write-Output ""
Get-ChildItem $Env:BUILD_PATH_FULL
Write-Output ""
break
}
Start-Sleep -Seconds 3
}
Get-ChildItem $Env:BUILD_PATH_FULL
Write-Output ""

View File

@@ -1,55 +1,18 @@
Get-Process
# Copy .upmconfig.toml if it exists
if (Test-Path "C:\githubhome\.upmconfig.toml") {
Write-Host "Copying .upmconfig.toml to $Env:USERPROFILE\.upmconfig.toml"
Copy-Item -Path "C:\githubhome\.upmconfig.toml" -Destination "$Env:USERPROFILE\.upmconfig.toml" -Force
} else {
Write-Host "No .upmconfig.toml found at C:\githubhome"
}
# Import any necessary registry keys, ie: location of windows 10 sdk
# No guarantee that there will be any necessary registry keys, ie: tvOS
Get-ChildItem -Path c:\regkeys -File | ForEach-Object { reg import $_.fullname }
Get-ChildItem -Path c:\regkeys -File | Foreach {reg import $_.fullname}
# Register the Visual Studio installation so Unity can find it
regsvr32 C:\ProgramData\Microsoft\VisualStudio\Setup\x64\Microsoft.VisualStudio.Setup.Configuration.Native.dll
# Kill the regsvr process
Get-Process -Name regsvr32 | ForEach-Object { Stop-Process -Id $_.Id -Force }
# Install Visual C++ 2013 Redistributables
. "c:\steps\install_vcredist13.ps1"
# Setup Git Credentials
. "c:\steps\set_gitcredential.ps1"
if ($env:ENABLE_GPU -eq "true") {
# Install LLVMpipe software graphics driver
. "c:\steps\install_llvmpipe.ps1"
}
& "c:\steps\set_gitcredential.ps1"
# Activate Unity
if ($env:SKIP_ACTIVATION -ne "true") {
. "c:\steps\activate.ps1"
# If we didn't activate successfully, exit with the exit code from the activation step.
if ($ACTIVATION_EXIT_CODE -ne 0) {
exit $ACTIVATION_EXIT_CODE
}
}
else {
Write-Host "Skipping activation"
}
& "c:\steps\activate.ps1"
# Build the project
. "c:\steps\build.ps1"
& "c:\steps\build.ps1"
# Free the seat for the activated license
if ($env:SKIP_ACTIVATION -ne "true") {
. "c:\steps\return_license.ps1"
}
Get-Process
exit $BUILD_EXIT_CODE
& "c:\steps\return_license.ps1"

View File

@@ -1,56 +0,0 @@
$Private:repo = "mmozeiko/build-mesa"
$Private:downloadPath = "$Env:TEMP\mesa.zip"
$Private:extractPath = "$Env:TEMP\mesa"
$Private:destinationPath = "$Env:UNITY_PATH\Editor\"
$Private:version = "25.1.0"
$LLVMPIPE_INSTALLED = "false"
try {
# Get the release info from GitHub API (version fixed to decrease probability of breakage)
$releaseUrl = "https://api.github.com/repos/$repo/releases/tags/$version"
$release = Invoke-RestMethod -Uri $releaseUrl -Headers @{ "User-Agent" = "PowerShell" }
# Get the download URL for the zip asset
$zipUrl = $release.assets | Where-Object { $_.name -like "mesa-llvmpipe-x64*.zip" } | Select-Object -First 1 -ExpandProperty browser_download_url
if (-not $zipUrl) {
throw "No zip file found in the latest release."
}
# Download the zip file
Write-Host "Downloading $zipUrl..."
Invoke-WebRequest -Uri $zipUrl -OutFile $downloadPath
# Create extraction directory if it doesn't exist
if (-not (Test-Path $extractPath)) {
New-Item -ItemType Directory -Path $extractPath | Out-Null
}
# Extract the zip file
Write-Host "Extracting $downloadPath to $extractPath..."
Expand-Archive -Path $downloadPath -DestinationPath $extractPath -Force
# Create destination directory if it doesn't exist
if (-not (Test-Path $destinationPath)) {
New-Item -ItemType Directory -Path $destinationPath | Out-Null
}
# Copy extracted files to destination
Write-Host "Copying files to $destinationPath..."
Copy-Item -Path "$extractPath\*" -Destination $destinationPath -Recurse -Force
Write-Host "Successfully downloaded, extracted, and copied Mesa files to $destinationPath"
$LLVMPIPE_INSTALLED = "true"
} catch {
Write-Error "An error occurred: $_"
} finally {
# Clean up temporary files
if (Test-Path $downloadPath) {
Remove-Item $downloadPath -Force
}
if (Test-Path $extractPath) {
Remove-Item $extractPath -Recurse -Force
}
}

View File

@@ -1,11 +0,0 @@
# For some reason, Unity is failing in github actions windows runners
# due to missing Visual C++ 2013 redistributables.
# This script downloads and installs the required redistributables.
Write-Output ""
Write-Output "#########################################################"
Write-Output "# Installing Visual C++ Redistributables (2013) #"
Write-Output "#########################################################"
Write-Output ""
choco install vcredist2013 -y --no-progress

View File

@@ -1,61 +1,7 @@
# Return the active Unity license
Write-Output ""
Write-Output "###########################"
Write-Output "# Return License #"
Write-Output "###########################"
Write-Output ""
if (($null -ne ${env:UNITY_LICENSING_SERVER}))
{
Write-Output "Returning floating license: ""$env:FLOATING_LICENSE"""
Start-Process -FilePath "$Env:UNITY_PATH\Editor\Data\Resources\Licensing\Client\Unity.Licensing.Client.exe" `
-ArgumentList "--return-floating ""$env:FLOATING_LICENSE"" " `
-NoNewWindow `
-Wait
}
elseif (($null -ne ${env:UNITY_SERIAL}) -and ($null -ne ${env:UNITY_EMAIL}) -and ($null -ne ${env:UNITY_PASSWORD}))
{
#
# SERIAL LICENSE MODE
#
# This will return the license that is currently in use.
#
$RETURN_LICENSE_OUTPUT = Start-Process -FilePath "$Env:UNITY_PATH/Editor/Unity.exe" `
-NoNewWindow `
-PassThru `
-ArgumentList "-batchmode `
-quit `
-nographics `
-username $Env:UNITY_EMAIL `
-password $Env:UNITY_PASSWORD `
-returnlicense `
-projectPath c:/BlankProject `
-logfile -"
# Cache the handle so exit code works properly
# https://stackoverflow.com/questions/10262231/obtaining-exitcode-using-start-process-and-waitforexit-instead-of-wait
$unityHandle = $RETURN_LICENSE_OUTPUT.Handle
while ($true) {
if ($RETURN_LICENSE_OUTPUT.HasExited) {
$RETURN_LICENSE_EXIT_CODE = $RETURN_LICENSE_OUTPUT.ExitCode
# Display results
if ($RETURN_LICENSE_EXIT_CODE -eq 0)
{
Write-Output "License Return Succeeded"
} else
{
Write-Output "License Return failed, with exit code $RETURN_LICENSE_EXIT_CODE"
Write-Output "::warning ::License Return failed! If this is a Pro License you might need to manually `
free the seat in your Unity admin panel or you might run out of seats to activate with."
}
break
}
Start-Sleep -Seconds 3
}
}
& "C:\Program Files\Unity\Hub\Editor\$Env:UNITY_VERSION\Editor\Unity.exe" -batchmode -quit -nographics `
-username $Env:UNITY_EMAIL `
-password $Env:UNITY_PASSWORD `
-returnlicense `
-projectPath "c:/BlankProject" `
-logfile | Out-Host

View File

@@ -1,16 +1,16 @@
if ($null -eq ${env:GIT_PRIVATE_TOKEN}) {
if ([string]::IsNullOrEmpty($env:GIT_PRIVATE_TOKEN)) {
Write-Host "GIT_PRIVATE_TOKEN unset skipping"
}
else {
Write-Host "GIT_PRIVATE_TOKEN is set configuring git credentials"
git config --global credential.helper store
git config --global --replace-all url."https://token:$env:GIT_PRIVATE_TOKEN@github.com/".insteadOf "ssh://git@github.com/"
git config --global --add url."https://token:$env:GIT_PRIVATE_TOKEN@github.com/".insteadOf "git@github.com"
git config --global --add url."https://token:$env:GIT_PRIVATE_TOKEN@github.com/".insteadOf "https://github.com/"
git config --global url."https://ssh:$env:GIT_PRIVATE_TOKEN@github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://git:$env:GIT_PRIVATE_TOKEN@github.com/".insteadOf "git@github.com:"
git config --global --replace-all "url.https://token:$env:GIT_PRIVATE_TOKEN@github.com/".insteadOf "ssh://git@github.com/"
git config --global --add "url.https://token:$env:GIT_PRIVATE_TOKEN@github.com/".insteadOf "git@github.com"
git config --global --add "url.https://token:$env:GIT_PRIVATE_TOKEN@github.com/".insteadOf "https://github.com/"
git config --global "url.https://ssh:$env:GIT_PRIVATE_TOKEN@github.com/".insteadOf "ssh://git@github.com/"
git config --global "url.https://git:$env:GIT_PRIVATE_TOKEN@github.com/".insteadOf "git@github.com:"
}
Write-Host "---------- git config --list -------------"

View File

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

View File

@@ -25,6 +25,6 @@ module.exports = {
// 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'],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
setupFilesAfterEnv: ['<rootDir>/src/jest.setup.ts'],
};

View File

@@ -1,2 +0,0 @@
const fetch = require('node-fetch');
global.fetch = fetch;

View File

@@ -7,19 +7,18 @@
"author": "Webber <webber@takken.io>",
"license": "MIT",
"scripts": {
"prepare": "lefthook install",
"prepare": "lefthook install && npx husky uninstall -y",
"build": "yarn && tsc && ncc build lib --source-map --license licenses.txt",
"lint": "prettier --check \"src/**/*.{js,ts}\" && eslint src/**/*.ts",
"format": "prettier --write \"src/**/*.{js,ts}\"",
"cli": "yarn ts-node src/index.ts -m cli",
"gcp-secrets-tests": "cross-env providerStrategy=aws cloudRunnerTests=true inputPullCommand=\"gcp-secret-manager\" populateOverride=true pullInputList=UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD yarn test -i -t \"cloud runner\"",
"gcp-secrets-cli": "cross-env cloudRunnerTests=true USE_IL2CPP=false inputPullCommand=\"gcp-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
"aws-secrets-cli": "cross-env cloudRunnerTests=true inputPullCommand=\"aws-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
"gcp-secrets-tests": "cross-env providerStrategy=aws cloudRunnerTests=true readInputOverrideCommand=\"gcp-secret-manager\" populateOverride=true readInputFromOverrideList=UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD yarn test -i -t \"cloud runner\"",
"gcp-secrets-cli": "cross-env cloudRunnerTests=true readInputOverrideCommand=\"gcp-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --readInputFromOverrideList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
"aws-secrets-cli": "cross-env cloudRunnerTests=true readInputOverrideCommand=\"aws-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --readInputFromOverrideList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
"cli-aws": "cross-env providerStrategy=aws yarn run test-cli",
"cli-k8s": "cross-env providerStrategy=k8s yarn run test-cli",
"test-cli": "cross-env cloudRunnerTests=true yarn ts-node src/index.ts -m cli --projectPath test-project",
"test": "jest",
"test:ci": "jest --config=jest.ci.config.js --runInBand",
"test-i": "cross-env cloudRunnerTests=true yarn test -i -t \"cloud runner\"",
"test-i-*": "yarn run test-i-aws && yarn run test-i-k8s",
"test-i-aws": "cross-env cloudRunnerTests=true providerStrategy=aws yarn test -i -t \"cloud runner\"",
@@ -29,34 +28,27 @@
"node": ">=18.x"
},
"dependencies": {
"@actions/cache": "^4.0.0",
"@actions/core": "^1.11.1",
"@actions/exec": "^1.1.1",
"@actions/github": "^6.0.0",
"@aws-sdk/client-cloudformation": "^3.777.0",
"@aws-sdk/client-cloudwatch-logs": "^3.777.0",
"@aws-sdk/client-ecs": "^3.778.0",
"@aws-sdk/client-kinesis": "^3.777.0",
"@aws-sdk/client-s3": "^3.779.0",
"@actions/cache": "^3.1.3",
"@actions/core": "^1.10.0",
"@actions/exec": "^1.1.0",
"@actions/github": "^5.0.0",
"@kubernetes/client-node": "^0.16.3",
"@octokit/core": "^5.1.0",
"@octokit/core": "^3.5.1",
"async-wait-until": "^2.0.12",
"aws-sdk": "^2.1081.0",
"base-64": "^1.0.0",
"commander": "^9.0.0",
"commander-ts": "^0.2.0",
"kubernetes-client": "^9.0.0",
"md5": "^2.3.0",
"nanoid": "^3.3.1",
"reflect-metadata": "^0.1.13",
"semver": "^7.5.2",
"shell-quote": "^1.8.3",
"ts-md5": "^1.3.1",
"unity-changeset": "^3.1.0",
"unity-changeset": "^2.0.0",
"uuid": "^9.0.0",
"yaml": "^2.2.2"
},
"devDependencies": {
"@evilmartians/lefthook": "^1.2.9",
"@types/base-64": "^1.0.0",
"@types/jest": "^27.4.1",
"@types/node": "^17.0.23",
@@ -75,11 +67,9 @@
"jest-circus": "^27.5.1",
"jest-fail-on-console": "^3.0.2",
"js-yaml": "^4.1.0",
"lefthook": "^1.6.1",
"node-fetch": "2",
"prettier": "^2.5.1",
"ts-jest": "^27.1.3",
"ts-node": "10.8.1",
"ts-node": "10.4.0",
"typescript": "4.7.4",
"yarn-audit-fix": "^9.3.8"
},

View File

@@ -1,15 +0,0 @@
echo "installing game-ci cli"
if exist %UserProfile%\AppData\LocalLow\game-ci\ (
echo Installed Updating
git -C %UserProfile%\AppData\LocalLow\game-ci\ fetch
git -C %UserProfile%\AppData\LocalLow\game-ci\ reset --hard
git -C %UserProfile%\AppData\LocalLow\game-ci\ pull
git -C %UserProfile%\AppData\LocalLow\game-ci\ branch
) else (
echo Not Installed Downloading...
mkdir %UserProfile%\AppData\LocalLow\game-ci\
git clone https://github.com/game-ci/unity-builder %UserProfile%\AppData\LocalLow\game-ci\
)
call yarn --cwd %UserProfile%\AppData\LocalLow\game-ci\ install
call yarn --cwd %UserProfile%\AppData\LocalLow\game-ci\ run gcp-secrets-cli %* --projectPath %cd% --awsStackName game-ci-cli

View File

@@ -19,35 +19,23 @@ async function runMain() {
const buildParameters = await BuildParameters.create();
const baseImage = new ImageTag(buildParameters);
let exitCode = -1;
if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
await PlatformSetup.setup(buildParameters, actionFolder);
exitCode =
process.platform === 'darwin'
? await MacBuilder.run(actionFolder)
: await Docker.run(baseImage.toString(), {
workspace,
actionFolder,
...buildParameters,
});
if (process.platform === 'darwin') {
MacBuilder.run(actionFolder);
} else {
await Docker.run(baseImage.toString(), { workspace, actionFolder, ...buildParameters });
}
} else {
await CloudRunner.run(buildParameters, baseImage.toString());
exitCode = 0;
}
// Set output
await Output.setBuildVersion(buildParameters.buildVersion);
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
await Output.setEngineExitCode(exitCode);
if (exitCode !== 0) {
core.setFailed(`Build failed with exit code ${exitCode}`);
}
} catch (error) {
core.setFailed((error as Error).message);
}
}
runMain();

View File

@@ -1,29 +0,0 @@
// Integration test for exercising real GitHub check creation and updates.
import CloudRunner from '../model/cloud-runner/cloud-runner';
import UnityVersioning from '../model/unity-versioning';
import GitHub from '../model/github';
import { TIMEOUT_INFINITE, createParameters } from '../test-utils/cloud-runner-test-helpers';
const runIntegration = process.env.RUN_GITHUB_INTEGRATION_TESTS === 'true';
const describeOrSkip = runIntegration ? describe : describe.skip;
describeOrSkip('Cloud Runner Github Checks Integration', () => {
it(
'creates and updates a real GitHub check',
async () => {
const buildParameter = await createParameters({
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.read('test-project'),
asyncCloudRunner: `true`,
githubChecks: `true`,
});
await CloudRunner.setup(buildParameter);
const checkId = await GitHub.createGitHubCheck(`integration create`);
expect(checkId).not.toEqual('');
await GitHub.updateGitHubCheck(`1 ${new Date().toISOString()}`, `integration`);
await GitHub.updateGitHubCheck(`2 ${new Date().toISOString()}`, `integration`, `success`, `completed`);
},
TIMEOUT_INFINITE,
);
});

View File

@@ -1,3 +0,0 @@
import { fetch as undiciFetch, Headers, Request, Response } from 'undici';
Object.assign(globalThis, { fetch: undiciFetch, Headers, Request, Response });

View File

@@ -71,12 +71,6 @@ describe('BuildParameters', () => {
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ projectPath: mockValue }));
});
it('returns the build profile', async () => {
const mockValue = 'path/to/build_profile.asset';
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';
jest.spyOn(Input, 'buildName', 'get').mockReturnValue(mockValue);

View File

@@ -12,7 +12,6 @@ import { Cli } from './cli/cli';
import GitHub from './github';
import CloudRunnerOptions from './cloud-runner/options/cloud-runner-options';
import CloudRunner from './cloud-runner/cloud-runner';
import * as core from '@actions/core';
class BuildParameters {
// eslint-disable-next-line no-undef
@@ -22,18 +21,15 @@ class BuildParameters {
public customImage!: string;
public unitySerial!: string;
public unityLicensingServer!: string;
public skipActivation!: string;
public runnerTempPath!: string;
public targetPlatform!: string;
public projectPath!: string;
public buildProfile!: string;
public buildName!: string;
public buildPath!: string;
public buildFile!: string;
public buildMethod!: string;
public buildVersion!: string;
public manualExit!: boolean;
public enableGpu!: boolean;
public androidVersionCode!: string;
public androidKeystoreName!: string;
public androidKeystoreBase64!: string;
@@ -44,11 +40,6 @@ class BuildParameters {
public androidSdkManagerParameters!: string;
public androidExportType!: string;
public androidSymbolType!: string;
public dockerCpuLimit!: string;
public dockerMemoryLimit!: string;
public dockerIsolationMode!: string;
public containerRegistryRepository!: string;
public containerRegistryImageVersion!: string;
public customParameters!: string;
public sshAgent!: string;
@@ -56,22 +47,12 @@ class BuildParameters {
public providerStrategy!: string;
public gitPrivateToken!: string;
public awsStackName!: string;
public awsEndpoint?: string;
public awsCloudFormationEndpoint?: string;
public awsEcsEndpoint?: string;
public awsKinesisEndpoint?: string;
public awsCloudWatchLogsEndpoint?: string;
public awsS3Endpoint?: string;
public storageProvider!: string;
public rcloneRemote!: string;
public kubeConfig!: string;
public containerMemory!: string;
public containerCpu!: string;
public containerNamespace!: string;
public kubeVolumeSize!: string;
public kubeVolume!: string;
public kubeStorageClass!: string;
public runAsHostUser!: string;
public chownFilesTo!: string;
public commandHooks!: string;
public pullInputList!: string[];
@@ -84,8 +65,6 @@ class BuildParameters {
public runNumber!: string;
public branch!: string;
public githubRepo!: string;
public cloudRunnerRepoName!: string;
public cloneDepth!: number;
public gitSha!: string;
public logId!: string;
public buildGuid!: string;
@@ -137,12 +116,10 @@ class BuildParameters {
if (!Input.unitySerial && GitHub.githubInputEnabled) {
// No serial was present, so it is a personal license that we need to convert
if (!Input.unityLicense) {
throw new Error(
`Missing Unity License File and no Serial was found. If this
throw new Error(`Missing Unity License File and no Serial was found. If this
is a personal license, make sure to follow the activation
steps and set the UNITY_LICENSE GitHub secret or enter a Unity
serial number inside the UNITY_SERIAL GitHub secret.`,
);
serial number inside the UNITY_SERIAL GitHub secret.`);
}
unitySerial = this.getSerialFromLicenseFile(Input.unityLicense);
} else {
@@ -150,28 +127,20 @@ class BuildParameters {
}
}
if (unitySerial !== undefined && unitySerial.length === 27) {
core.setSecret(unitySerial);
core.setSecret(`${unitySerial.slice(0, -4)}XXXX`);
}
return {
editorVersion,
customImage: Input.customImage,
unitySerial,
unityLicensingServer: Input.unityLicensingServer,
skipActivation: Input.skipActivation,
runnerTempPath: Input.runnerTempPath,
targetPlatform: Input.targetPlatform,
projectPath: Input.projectPath,
buildProfile: Input.buildProfile,
buildName: Input.buildName,
buildPath: `${Input.buildsPath}/${Input.targetPlatform}`,
buildFile,
buildMethod: Input.buildMethod,
buildVersion,
manualExit: Input.manualExit,
enableGpu: Input.enableGpu,
androidVersionCode,
androidKeystoreName: Input.androidKeystoreName,
androidKeystoreBase64: Input.androidKeystoreBase64,
@@ -185,20 +154,13 @@ class BuildParameters {
customParameters: Input.customParameters,
sshAgent: Input.sshAgent,
sshPublicKeysDirectoryPath: Input.sshPublicKeysDirectoryPath,
gitPrivateToken: Input.gitPrivateToken ?? (await GithubCliReader.GetGitHubAuthToken()),
runAsHostUser: Input.runAsHostUser,
gitPrivateToken: Input.gitPrivateToken || (await GithubCliReader.GetGitHubAuthToken()),
chownFilesTo: Input.chownFilesTo,
dockerCpuLimit: Input.dockerCpuLimit,
dockerMemoryLimit: Input.dockerMemoryLimit,
dockerIsolationMode: Input.dockerIsolationMode,
containerRegistryRepository: Input.containerRegistryRepository,
containerRegistryImageVersion: Input.containerRegistryImageVersion,
providerStrategy: CloudRunnerOptions.providerStrategy,
buildPlatform: CloudRunnerOptions.buildPlatform,
kubeConfig: CloudRunnerOptions.kubeConfig,
containerMemory: CloudRunnerOptions.containerMemory,
containerCpu: CloudRunnerOptions.containerCpu,
containerNamespace: CloudRunnerOptions.containerNamespace,
kubeVolumeSize: CloudRunnerOptions.kubeVolumeSize,
kubeVolume: CloudRunnerOptions.kubeVolume,
postBuildContainerHooks: CloudRunnerOptions.postBuildContainerHooks,
@@ -208,19 +170,9 @@ class BuildParameters {
branch: Input.branch.replace('/head', '') || (await GitRepoReader.GetBranch()),
cloudRunnerBranch: CloudRunnerOptions.cloudRunnerBranch.split('/').reverse()[0],
cloudRunnerDebug: CloudRunnerOptions.cloudRunnerDebug,
githubRepo: (Input.githubRepo ?? (await GitRepoReader.GetRemote())) || CloudRunnerOptions.cloudRunnerRepoName,
cloudRunnerRepoName: CloudRunnerOptions.cloudRunnerRepoName,
cloneDepth: Number.parseInt(CloudRunnerOptions.cloneDepth),
githubRepo: Input.githubRepo || (await GitRepoReader.GetRemote()) || 'game-ci/unity-builder',
isCliMode: Cli.isCliMode,
awsStackName: CloudRunnerOptions.awsStackName,
awsEndpoint: CloudRunnerOptions.awsEndpoint,
awsCloudFormationEndpoint: CloudRunnerOptions.awsCloudFormationEndpoint,
awsEcsEndpoint: CloudRunnerOptions.awsEcsEndpoint,
awsKinesisEndpoint: CloudRunnerOptions.awsKinesisEndpoint,
awsCloudWatchLogsEndpoint: CloudRunnerOptions.awsCloudWatchLogsEndpoint,
awsS3Endpoint: CloudRunnerOptions.awsS3Endpoint,
storageProvider: CloudRunnerOptions.storageProvider,
rcloneRemote: CloudRunnerOptions.rcloneRemote,
gitSha: Input.gitSha,
logId: customAlphabet(CloudRunnerConstants.alphabet, 9)(),
buildGuid: CloudRunnerBuildGuid.generateGuid(Input.runNumber, Input.targetPlatform),

View File

@@ -10,6 +10,8 @@ import { LfsHashing } from '../cloud-runner/services/utility/lfs-hashing';
import { RemoteClient } from '../cloud-runner/remote-client';
import CloudRunnerOptionsReader from '../cloud-runner/options/cloud-runner-options-reader';
import GitHub from '../github';
import { CloudRunnerFolders } from '../cloud-runner/options/cloud-runner-folders';
import { CloudRunnerSystem } from '../cloud-runner/services/core/cloud-runner-system';
import { OptionValues } from 'commander';
import { InputKey } from '../input';
@@ -52,7 +54,6 @@ export class Cli {
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.parse(process.argv);
Cli.options = program.opts();
@@ -109,7 +110,7 @@ export class Cli {
const buildParameter = await BuildParameters.create();
const baseImage = new ImageTag(buildParameter);
return (await CloudRunner.run(buildParameter, baseImage.toString())).BuildResults;
return await CloudRunner.run(buildParameter, baseImage.toString());
}
@CliFunction(`async-workflow`, `runs a cloud runner build`)
@@ -118,7 +119,7 @@ export class Cli {
const baseImage = new ImageTag(buildParameter);
await CloudRunner.setup(buildParameter);
return (await CloudRunner.run(buildParameter, baseImage.toString())).BuildResults;
return await CloudRunner.run(buildParameter, baseImage.toString());
}
@CliFunction(`checks-update`, `runs a cloud runner build`)
@@ -172,4 +173,31 @@ export class Cli {
return await CloudRunner.Provider.watchWorkflow();
}
@CliFunction(`remote-cli-post-build`, `runs a cloud runner build`)
public static async PostCLIBuild(): Promise<string> {
core.info(`Running POST build tasks`);
await Caching.PushToCache(
CloudRunnerFolders.ToLinuxFolder(`${CloudRunnerFolders.cacheFolderForCacheKeyFull}/Library`),
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.libraryFolderAbsolute),
`lib-${CloudRunner.buildParameters.buildGuid}`,
);
await Caching.PushToCache(
CloudRunnerFolders.ToLinuxFolder(`${CloudRunnerFolders.cacheFolderForCacheKeyFull}/build`),
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.projectBuildFolderAbsolute),
`build-${CloudRunner.buildParameters.buildGuid}`,
);
if (!BuildParameters.shouldUseRetainedWorkspaceMode(CloudRunner.buildParameters)) {
await CloudRunnerSystem.Run(
`rm -r ${CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute)}`,
);
}
await RemoteClient.runCustomHookFiles(`after-build`);
return new Promise((result) => result(``));
}
}

View File

@@ -13,13 +13,9 @@ import CloudRunnerEnvironmentVariable from './options/cloud-runner-environment-v
import TestCloudRunner from './providers/test';
import LocalCloudRunner from './providers/local';
import LocalDockerCloudRunner from './providers/docker';
import loadProvider from './providers/provider-loader';
import GitHub from '../github';
import SharedWorkspaceLocking from './services/core/shared-workspace-locking';
import { FollowLogStreamService } from './services/core/follow-log-stream-service';
import CloudRunnerResult from './services/core/cloud-runner-result';
import CloudRunnerOptions from './options/cloud-runner-options';
import ResourceTracking from './services/core/resource-tracking';
class CloudRunner {
public static Provider: ProviderInterface;
@@ -28,10 +24,6 @@ class CloudRunner {
private static cloudRunnerEnvironmentVariables: CloudRunnerEnvironmentVariable[];
static lockedWorkspace: string = ``;
public static readonly retainedWorkspacePrefix: string = `retained-workspace`;
// When true, validates AWS CloudFormation templates even when using local-docker execution
// This is set by AWS_FORCE_PROVIDER=aws-local mode
public static validateAwsTemplates: boolean = false;
public static get isCloudRunnerEnvironment() {
return process.env[`GITHUB_ACTIONS`] !== `true`;
}
@@ -42,12 +34,10 @@ class CloudRunner {
CloudRunnerLogger.setup();
CloudRunnerLogger.log(`Setting up cloud runner`);
CloudRunner.buildParameters = buildParameters;
ResourceTracking.logAllocationSummary('setup');
await ResourceTracking.logDiskUsageSnapshot('setup');
if (CloudRunner.buildParameters.githubCheckId === ``) {
CloudRunner.buildParameters.githubCheckId = await GitHub.createGitHubCheck(CloudRunner.buildParameters.buildGuid);
}
await CloudRunner.setupSelectedBuildPlatform();
CloudRunner.setupSelectedBuildPlatform();
CloudRunner.defaultSecrets = TaskParameterSerializer.readDefaultSecrets();
CloudRunner.cloudRunnerEnvironmentVariables =
TaskParameterSerializer.createCloudRunnerEnvironmentVariables(buildParameters);
@@ -71,78 +61,14 @@ class CloudRunner {
FollowLogStreamService.Reset();
}
private static async setupSelectedBuildPlatform() {
private static setupSelectedBuildPlatform() {
CloudRunnerLogger.log(`Cloud Runner platform selected ${CloudRunner.buildParameters.providerStrategy}`);
// Detect LocalStack endpoints and handle AWS provider appropriately
// AWS_FORCE_PROVIDER options:
// - 'aws': Force AWS provider (requires LocalStack Pro with ECS support)
// - 'aws-local': Validate AWS templates/config but execute via local-docker (for CI without ECS)
// - unset/other: Auto-fallback to local-docker when LocalStack detected
const awsForceProvider = process.env.AWS_FORCE_PROVIDER || '';
const forceAwsProvider = awsForceProvider === 'aws' || awsForceProvider === 'true';
const useAwsLocalMode = awsForceProvider === 'aws-local';
const endpointsToCheck = [
process.env.AWS_ENDPOINT,
process.env.AWS_S3_ENDPOINT,
process.env.AWS_CLOUD_FORMATION_ENDPOINT,
process.env.AWS_ECS_ENDPOINT,
process.env.AWS_KINESIS_ENDPOINT,
process.env.AWS_CLOUD_WATCH_LOGS_ENDPOINT,
CloudRunnerOptions.awsEndpoint,
CloudRunnerOptions.awsS3Endpoint,
CloudRunnerOptions.awsCloudFormationEndpoint,
CloudRunnerOptions.awsEcsEndpoint,
CloudRunnerOptions.awsKinesisEndpoint,
CloudRunnerOptions.awsCloudWatchLogsEndpoint,
]
.filter((x) => typeof x === 'string')
.join(' ');
const isLocalStack = /localstack|localhost|127\.0\.0\.1/i.test(endpointsToCheck);
let provider = CloudRunner.buildParameters.providerStrategy;
let validateAwsTemplates = false;
if (provider === 'aws' && isLocalStack) {
if (useAwsLocalMode) {
// aws-local mode: Validate AWS templates but execute via local-docker
// This provides confidence in AWS CloudFormation without requiring LocalStack Pro
CloudRunnerLogger.log('AWS_FORCE_PROVIDER=aws-local: Validating AWS templates, executing via local-docker');
validateAwsTemplates = true;
provider = 'local-docker';
} else if (forceAwsProvider) {
// Force full AWS provider (requires LocalStack Pro with ECS support)
CloudRunnerLogger.log(
'LocalStack endpoints detected but AWS_FORCE_PROVIDER=aws; using full AWS provider (requires ECS support)',
);
} else {
// Auto-fallback to local-docker
CloudRunnerLogger.log('LocalStack endpoints detected; routing provider to local-docker for this run');
CloudRunnerLogger.log(
'Note: Set AWS_FORCE_PROVIDER=aws-local to validate AWS templates with local-docker execution',
);
provider = 'local-docker';
}
}
// Store whether we should validate AWS templates (used by aws-local mode)
CloudRunner.validateAwsTemplates = validateAwsTemplates;
switch (provider) {
switch (CloudRunner.buildParameters.providerStrategy) {
case 'k8s':
CloudRunner.Provider = new Kubernetes(CloudRunner.buildParameters);
break;
case 'aws':
CloudRunner.Provider = new AwsBuildPlatform(CloudRunner.buildParameters);
// Validate that AWS provider is actually being used when expected
if (isLocalStack && forceAwsProvider) {
CloudRunnerLogger.log('✓ AWS provider initialized with LocalStack - AWS functionality will be validated');
} else if (isLocalStack && !forceAwsProvider) {
CloudRunnerLogger.log(
'⚠ WARNING: AWS provider was requested but LocalStack detected without AWS_FORCE_PROVIDER',
);
CloudRunnerLogger.log('⚠ This may cause AWS functionality tests to fail validation');
}
break;
case 'test':
CloudRunner.Provider = new TestCloudRunner();
@@ -153,46 +79,19 @@ class CloudRunner {
case 'local-system':
CloudRunner.Provider = new LocalCloudRunner();
break;
case 'local':
CloudRunner.Provider = new LocalCloudRunner();
break;
default:
// Try to load provider using the dynamic loader for unknown providers
try {
CloudRunner.Provider = await loadProvider(provider, CloudRunner.buildParameters);
} catch (error: any) {
CloudRunnerLogger.log(`Failed to load provider '${provider}' using dynamic loader: ${error.message}`);
CloudRunnerLogger.log('Falling back to local provider...');
CloudRunner.Provider = new LocalCloudRunner();
}
break;
}
// Final validation: Ensure provider matches expectations
const finalProviderName = CloudRunner.Provider.constructor.name;
if (CloudRunner.buildParameters.providerStrategy === 'aws' && finalProviderName !== 'AWSBuildEnvironment') {
CloudRunnerLogger.log(`⚠ WARNING: Expected AWS provider but got ${finalProviderName}`);
CloudRunnerLogger.log('⚠ AWS functionality tests may not be validating AWS services correctly');
}
}
static async run(buildParameters: BuildParameters, baseImage: string) {
if (baseImage.includes(`undefined`)) {
throw new Error(`baseImage is undefined`);
}
await CloudRunner.setup(buildParameters);
// When aws-local mode is enabled, validate AWS CloudFormation templates
// This ensures AWS templates are correct even when executing via local-docker
if (CloudRunner.validateAwsTemplates) {
await CloudRunner.validateAwsCloudFormationTemplates();
}
if (!CloudRunner.buildParameters.isCliMode) core.startGroup('Setup shared cloud runner resources');
await CloudRunner.Provider.setupWorkflow(
CloudRunner.buildParameters.buildGuid,
CloudRunner.buildParameters,
CloudRunner.buildParameters.branch,
CloudRunner.defaultSecrets,
);
if (!CloudRunner.buildParameters.isCliMode) core.endGroup();
try {
if (buildParameters.maxRetainedWorkspaces > 0) {
CloudRunner.lockedWorkspace = SharedWorkspaceLocking.NewWorkspaceName();
@@ -215,7 +114,11 @@ class CloudRunner {
CloudRunner.lockedWorkspace = ``;
}
}
await CloudRunner.updateStatusWithBuildParameters();
const content = { ...CloudRunner.buildParameters };
content.gitPrivateToken = ``;
content.unitySerial = ``;
const jsonContent = JSON.stringify(content, undefined, 4);
await GitHub.updateGitHubCheck(jsonContent, CloudRunner.buildParameters.buildGuid);
const output = await new WorkflowCompositionRoot().run(
new CloudRunnerStepParameters(
baseImage,
@@ -223,15 +126,16 @@ class CloudRunner {
CloudRunner.defaultSecrets,
),
);
if (!CloudRunner.buildParameters.isCliMode) core.startGroup('Cleanup shared cloud runner resources');
await CloudRunner.Provider.cleanupWorkflow(
CloudRunner.buildParameters.buildGuid,
CloudRunner.buildParameters,
CloudRunner.buildParameters.branch,
CloudRunner.defaultSecrets,
);
CloudRunnerLogger.log(`Cleanup complete`);
if (!CloudRunner.buildParameters.isCliMode) core.endGroup();
if (buildParameters.asyncWorkflow && this.isCloudRunnerEnvironment && this.isCloudRunnerAsyncEnvironment) {
await GitHub.updateGitHubCheck(CloudRunner.buildParameters.buildGuid, `success`, `success`, `completed`);
}
await GitHub.updateGitHubCheck(CloudRunner.buildParameters.buildGuid, `success`, `success`, `completed`);
if (BuildParameters.shouldUseRetainedWorkspaceMode(buildParameters)) {
const workspace = CloudRunner.lockedWorkspace || ``;
@@ -258,7 +162,7 @@ class CloudRunner {
CloudRunner.Provider.garbageCollect(``, true, buildParameters.garbageMaxAge, true, true);
}
return new CloudRunnerResult(buildParameters, output, true, true, false);
return output;
} catch (error: any) {
CloudRunnerLogger.log(JSON.stringify(error, undefined, 4));
await GitHub.updateGitHubCheck(
@@ -272,72 +176,5 @@ class CloudRunner {
throw error;
}
}
private static async updateStatusWithBuildParameters() {
const content = { ...CloudRunner.buildParameters };
content.gitPrivateToken = ``;
content.unitySerial = ``;
content.unityEmail = ``;
content.unityPassword = ``;
const jsonContent = JSON.stringify(content, undefined, 4);
await GitHub.updateGitHubCheck(jsonContent, CloudRunner.buildParameters.buildGuid);
}
/**
* Validates AWS CloudFormation templates without deploying them.
* Used by aws-local mode to ensure AWS templates are correct when executing via local-docker.
* This provides confidence that AWS ECS deployments would work with the generated templates.
*/
private static async validateAwsCloudFormationTemplates() {
CloudRunnerLogger.log('=== AWS CloudFormation Template Validation (aws-local mode) ===');
try {
// Import AWS template formations
const { BaseStackFormation } = await import('./providers/aws/cloud-formations/base-stack-formation');
const { TaskDefinitionFormation } = await import('./providers/aws/cloud-formations/task-definition-formation');
// Validate base stack template
const baseTemplate = BaseStackFormation.formation;
CloudRunnerLogger.log(`✓ Base stack template generated (${baseTemplate.length} chars)`);
// Check for required resources in base stack
const requiredBaseResources = ['AWS::EC2::VPC', 'AWS::ECS::Cluster', 'AWS::S3::Bucket', 'AWS::IAM::Role'];
for (const resource of requiredBaseResources) {
if (baseTemplate.includes(resource)) {
CloudRunnerLogger.log(` ✓ Contains ${resource}`);
} else {
throw new Error(`Base stack template missing required resource: ${resource}`);
}
}
// Validate task definition template
const taskTemplate = TaskDefinitionFormation.formation;
CloudRunnerLogger.log(`✓ Task definition template generated (${taskTemplate.length} chars)`);
// Check for required resources in task definition
const requiredTaskResources = ['AWS::ECS::TaskDefinition', 'AWS::Logs::LogGroup'];
for (const resource of requiredTaskResources) {
if (taskTemplate.includes(resource)) {
CloudRunnerLogger.log(` ✓ Contains ${resource}`);
} else {
throw new Error(`Task definition template missing required resource: ${resource}`);
}
}
// Validate YAML syntax by checking for common patterns
if (!baseTemplate.includes('AWSTemplateFormatVersion')) {
throw new Error('Base stack template missing AWSTemplateFormatVersion');
}
if (!taskTemplate.includes('AWSTemplateFormatVersion')) {
throw new Error('Task definition template missing AWSTemplateFormatVersion');
}
CloudRunnerLogger.log('=== AWS CloudFormation templates validated successfully ===');
CloudRunnerLogger.log('Note: Actual execution will use local-docker provider');
} catch (error: any) {
CloudRunnerLogger.log(`AWS CloudFormation template validation failed: ${error.message}`);
throw error;
}
}
}
export default CloudRunner;

View File

@@ -9,7 +9,12 @@ export class CloudRunnerError {
CloudRunnerLogger.error(JSON.stringify(error, undefined, 4));
core.setFailed('Cloud Runner failed');
if (CloudRunner.Provider !== undefined) {
await CloudRunner.Provider.cleanupWorkflow(buildParameters, buildParameters.branch, secrets);
await CloudRunner.Provider.cleanupWorkflow(
buildParameters.buildGuid,
buildParameters,
buildParameters.branch,
secrets,
);
}
}
}

View File

@@ -73,7 +73,7 @@ export class CloudRunnerFolders {
}
public static get unityBuilderRepoUrl(): string {
return `https://${CloudRunner.buildParameters.gitPrivateToken}@github.com/${CloudRunner.buildParameters.cloudRunnerRepoName}.git`;
return `https://${CloudRunner.buildParameters.gitPrivateToken}@github.com/game-ci/unity-builder.git`;
}
public static get targetBuildRepoUrl(): string {

View File

@@ -74,14 +74,6 @@ class CloudRunnerOptions {
return CloudRunnerOptions.getInput('githubRepoName') || CloudRunnerOptions.githubRepo?.split(`/`)[1] || '';
}
static get cloudRunnerRepoName(): string {
return CloudRunnerOptions.getInput('cloudRunnerRepoName') || 'game-ci/unity-builder';
}
static get cloneDepth(): string {
return CloudRunnerOptions.getInput('cloneDepth') || '50';
}
static get finalHooks(): string[] {
return CloudRunnerOptions.getInput('finalHooks')?.split(',') || [];
}
@@ -111,14 +103,14 @@ class CloudRunnerOptions {
static get buildPlatform(): string {
const input = CloudRunnerOptions.getInput('buildPlatform');
if (input && input !== '') {
if (input) {
return input;
}
if (CloudRunnerOptions.providerStrategy !== 'local') {
return 'linux';
}
return process.platform;
return ``;
}
static get cloudRunnerBranch(): string {
@@ -143,10 +135,6 @@ class CloudRunnerOptions {
return CloudRunnerOptions.getInput('containerMemory') || `3072`;
}
static get containerNamespace(): string {
return CloudRunnerOptions.getInput('containerNamespace') || `default`;
}
static get customJob(): string {
return CloudRunnerOptions.getInput('customJob') || '';
}
@@ -207,42 +195,6 @@ class CloudRunnerOptions {
return CloudRunnerOptions.getInput('awsStackName') || 'game-ci';
}
static get awsEndpoint(): string | undefined {
return CloudRunnerOptions.getInput('awsEndpoint');
}
static get awsCloudFormationEndpoint(): string | undefined {
return CloudRunnerOptions.getInput('awsCloudFormationEndpoint') || CloudRunnerOptions.awsEndpoint;
}
static get awsEcsEndpoint(): string | undefined {
return CloudRunnerOptions.getInput('awsEcsEndpoint') || CloudRunnerOptions.awsEndpoint;
}
static get awsKinesisEndpoint(): string | undefined {
return CloudRunnerOptions.getInput('awsKinesisEndpoint') || CloudRunnerOptions.awsEndpoint;
}
static get awsCloudWatchLogsEndpoint(): string | undefined {
return CloudRunnerOptions.getInput('awsCloudWatchLogsEndpoint') || CloudRunnerOptions.awsEndpoint;
}
static get awsS3Endpoint(): string | undefined {
return CloudRunnerOptions.getInput('awsS3Endpoint') || CloudRunnerOptions.awsEndpoint;
}
// ### ### ###
// Storage
// ### ### ###
static get storageProvider(): string {
return CloudRunnerOptions.getInput('storageProvider') || 's3';
}
static get rcloneRemote(): string {
return CloudRunnerOptions.getInput('rcloneRemote') || '';
}
// ### ### ###
// K8s
// ### ### ###
@@ -295,10 +247,6 @@ class CloudRunnerOptions {
return CloudRunnerOptions.getInput('asyncCloudRunner') === 'true';
}
public static get resourceTracking(): boolean {
return CloudRunnerOptions.getInput('resourceTracking') === 'true';
}
public static get useLargePackages(): boolean {
return CloudRunnerOptions.getInput(`useLargePackages`) === `true`;
}

View File

@@ -1,222 +0,0 @@
# Provider Loader Dynamic Imports
## What is a Provider?
A **provider** is a pluggable backend that Cloud Runner uses to run builds and workflows. Examples include **AWS**, **Kubernetes**, or local execution. Each provider implements the [ProviderInterface](https://github.com/game-ci/unity-builder/blob/main/src/model/cloud-runner/providers/provider-interface.ts), which defines the common lifecycle methods (setup, run, cleanup, garbage collection, etc.).
This abstraction makes Cloud Runner flexible: you can switch execution environments or add your own provider (via npm package, GitHub repo, or local path) without changing the rest of your pipeline.
## Dynamic Provider Loading
The provider loader now supports dynamic loading of providers from multiple sources including local file paths, GitHub repositories, and NPM packages.
## Features
- **Local File Paths**: Load providers from relative or absolute file paths
- **GitHub URLs**: Clone and load providers from GitHub repositories with automatic updates
- **NPM Packages**: Load providers from installed NPM packages
- **Automatic Updates**: GitHub repositories are automatically updated when changes are available
- **Caching**: Local caching of cloned repositories for improved performance
- **Fallback Support**: Graceful fallback to local provider if loading fails
## Usage Examples
### Loading Built-in Providers
```typescript
import { ProviderLoader } from './provider-loader';
// Load built-in providers
const awsProvider = await ProviderLoader.loadProvider('aws', buildParameters);
const k8sProvider = await ProviderLoader.loadProvider('k8s', buildParameters);
```
### Loading Local Providers
```typescript
// Load from relative path
const localProvider = await ProviderLoader.loadProvider('./my-local-provider', buildParameters);
// Load from absolute path
const absoluteProvider = await ProviderLoader.loadProvider('/path/to/provider', buildParameters);
```
### Loading GitHub Providers
```typescript
// Load from GitHub URL
const githubProvider = await ProviderLoader.loadProvider(
'https://github.com/user/my-provider',
buildParameters
);
// Load from specific branch
const branchProvider = await ProviderLoader.loadProvider(
'https://github.com/user/my-provider/tree/develop',
buildParameters
);
// Load from specific path in repository
const pathProvider = await ProviderLoader.loadProvider(
'https://github.com/user/my-provider/tree/main/src/providers',
buildParameters
);
// Shorthand notation
const shorthandProvider = await ProviderLoader.loadProvider('user/repo', buildParameters);
const branchShorthand = await ProviderLoader.loadProvider('user/repo@develop', buildParameters);
```
### Loading NPM Packages
```typescript
// Load from NPM package
const npmProvider = await ProviderLoader.loadProvider('my-provider-package', buildParameters);
// Load from scoped NPM package
const scopedProvider = await ProviderLoader.loadProvider('@scope/my-provider', buildParameters);
```
## Provider Interface
All providers must implement the `ProviderInterface`:
```typescript
interface ProviderInterface {
cleanupWorkflow(): Promise<void>;
setupWorkflow(buildGuid: string, buildParameters: BuildParameters, branchName: string, defaultSecretsArray: any[]): Promise<void>;
runTaskInWorkflow(buildGuid: string, task: string, workingDirectory: string, buildVolumeFolder: string, environmentVariables: any[], secrets: any[]): Promise<string>;
garbageCollect(): Promise<void>;
listResources(): Promise<ProviderResource[]>;
listWorkflow(): Promise<ProviderWorkflow[]>;
watchWorkflow(): Promise<void>;
}
```
## Example Provider Implementation
```typescript
// my-provider.ts
import { ProviderInterface } from './provider-interface';
import BuildParameters from './build-parameters';
export default class MyProvider implements ProviderInterface {
constructor(private buildParameters: BuildParameters) {}
async cleanupWorkflow(): Promise<void> {
// Cleanup logic
}
async setupWorkflow(buildGuid: string, buildParameters: BuildParameters, branchName: string, defaultSecretsArray: any[]): Promise<void> {
// Setup logic
}
async runTaskInWorkflow(buildGuid: string, task: string, workingDirectory: string, buildVolumeFolder: string, environmentVariables: any[], secrets: any[]): Promise<string> {
// Task execution logic
return 'Task completed';
}
async garbageCollect(): Promise<void> {
// Garbage collection logic
}
async listResources(): Promise<ProviderResource[]> {
return [];
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
return [];
}
async watchWorkflow(): Promise<void> {
// Watch logic
}
}
```
## Utility Methods
### Analyze Provider Source
```typescript
// Analyze a provider source without loading it
const sourceInfo = ProviderLoader.analyzeProviderSource('https://github.com/user/repo');
console.log(sourceInfo.type); // 'github'
console.log(sourceInfo.owner); // 'user'
console.log(sourceInfo.repo); // 'repo'
```
### Clean Up Cache
```typescript
// Clean up old cached repositories (older than 30 days)
await ProviderLoader.cleanupCache();
// Clean up repositories older than 7 days
await ProviderLoader.cleanupCache(7);
```
### Get Available Providers
```typescript
// Get list of built-in providers
const providers = ProviderLoader.getAvailableProviders();
console.log(providers); // ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local']
```
## Supported URL Formats
### GitHub URLs
- `https://github.com/user/repo`
- `https://github.com/user/repo.git`
- `https://github.com/user/repo/tree/branch`
- `https://github.com/user/repo/tree/branch/path/to/provider`
- `git@github.com:user/repo.git`
### Shorthand GitHub References
- `user/repo`
- `user/repo@branch`
- `user/repo@branch/path/to/provider`
### Local Paths
- `./relative/path`
- `../relative/path`
- `/absolute/path`
- `C:\\path\\to\\provider` (Windows)
### NPM Packages
- `package-name`
- `@scope/package-name`
## Caching
GitHub repositories are automatically cached in the `.provider-cache` directory. The cache key is generated based on the repository owner, name, and branch. This ensures that:
1. Repositories are only cloned once
2. Updates are checked and applied automatically
3. Performance is improved for repeated loads
4. Storage is managed efficiently
## Error Handling
The provider loader includes comprehensive error handling:
- **Missing packages**: Clear error messages when providers cannot be found
- **Interface validation**: Ensures providers implement the required interface
- **Git operations**: Handles network issues and repository access problems
- **Fallback mechanism**: Falls back to local provider if loading fails
## Configuration
The provider loader can be configured through environment variables:
- `PROVIDER_CACHE_DIR`: Custom cache directory (default: `.provider-cache`)
- `GIT_TIMEOUT`: Git operation timeout in milliseconds (default: 30000)
## Best Practices
1. **Use specific branches or tags**: Always specify the branch or specific tag when loading from GitHub
2. **Implement proper error handling**: Wrap provider loading in try-catch blocks
3. **Clean up regularly**: Use the cleanup utility to manage cache size
4. **Test locally first**: Test providers locally before deploying
5. **Use semantic versioning**: Tag your provider repositories for stable versions

View File

@@ -1,108 +1,61 @@
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import * as core from '@actions/core';
import {
CloudFormation,
CreateStackCommand,
// eslint-disable-next-line import/named
CreateStackCommandInput,
DescribeStacksCommand,
// eslint-disable-next-line import/named
DescribeStacksCommandInput,
ListStacksCommand,
// eslint-disable-next-line import/named
Parameter,
UpdateStackCommand,
// eslint-disable-next-line import/named
UpdateStackCommandInput,
waitUntilStackCreateComplete,
waitUntilStackUpdateComplete,
} from '@aws-sdk/client-cloudformation';
import * as SDK from 'aws-sdk';
import { BaseStackFormation } from './cloud-formations/base-stack-formation';
import crypto from 'node:crypto';
const DEFAULT_STACK_WAIT_TIME_SECONDS = 600;
function getStackWaitTime(): number {
const overrideValue = Number(process.env.CLOUD_RUNNER_AWS_STACK_WAIT_TIME ?? '');
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}
export class AWSBaseStack {
constructor(baseStackName: string) {
this.baseStackName = baseStackName;
}
private baseStackName: string;
async setupBaseStack(CF: CloudFormation) {
async setupBaseStack(CF: SDK.CloudFormation) {
const baseStackName = this.baseStackName;
const stackWaitTimeSeconds = getStackWaitTime();
const baseStack = BaseStackFormation.formation;
// Cloud Formation Input
const describeStackInput: DescribeStacksCommandInput = {
const describeStackInput: SDK.CloudFormation.DescribeStacksInput = {
StackName: baseStackName,
};
const parametersWithoutHash: Parameter[] = [{ ParameterKey: 'EnvironmentName', ParameterValue: baseStackName }];
const parametersWithoutHash: SDK.CloudFormation.Parameter[] = [
{ ParameterKey: 'EnvironmentName', ParameterValue: baseStackName },
];
const parametersHash = crypto
.createHash('md5')
.update(baseStack + JSON.stringify(parametersWithoutHash))
.digest('hex');
const parameters: Parameter[] = [
const parameters: SDK.CloudFormation.Parameter[] = [
...parametersWithoutHash,
...[{ ParameterKey: 'Version', ParameterValue: parametersHash }],
];
const updateInput: UpdateStackCommandInput = {
const updateInput: SDK.CloudFormation.UpdateStackInput = {
StackName: baseStackName,
TemplateBody: baseStack,
Parameters: parameters,
Capabilities: ['CAPABILITY_IAM'],
};
const createStackInput: CreateStackCommandInput = {
const createStackInput: SDK.CloudFormation.CreateStackInput = {
StackName: baseStackName,
TemplateBody: baseStack,
Parameters: parameters,
Capabilities: ['CAPABILITY_IAM'],
};
const stacks = await CF.send(
new ListStacksCommand({
StackStatusFilter: [
'CREATE_IN_PROGRESS',
'UPDATE_IN_PROGRESS',
'UPDATE_COMPLETE',
'CREATE_COMPLETE',
'ROLLBACK_COMPLETE',
],
}),
);
const stacks = await CF.listStacks({
StackStatusFilter: ['UPDATE_COMPLETE', 'CREATE_COMPLETE', 'ROLLBACK_COMPLETE'],
}).promise();
const stackNames = stacks.StackSummaries?.map((x) => x.StackName) || [];
const stackExists: boolean = stackNames.includes(baseStackName);
const stackExists: Boolean = stackNames.includes(baseStackName) || false;
const describeStack = async () => {
return await CF.send(new DescribeStacksCommand(describeStackInput));
return await CF.describeStacks(describeStackInput).promise();
};
try {
if (!stackExists) {
CloudRunnerLogger.log(`${baseStackName} stack does not exist (${JSON.stringify(stackNames)})`);
let created = false;
try {
await CF.send(new CreateStackCommand(createStackInput));
created = true;
} catch (error: any) {
const message = `${error?.name ?? ''} ${error?.message ?? ''}`;
if (message.includes('AlreadyExistsException')) {
CloudRunnerLogger.log(`Base stack already exists, continuing with describe`);
} else {
throw error;
}
}
if (created) {
CloudRunnerLogger.log(`created stack (version: ${parametersHash})`);
}
await CF.createStack(createStackInput).promise();
CloudRunnerLogger.log(`created stack (version: ${parametersHash})`);
}
const CFState = await describeStack();
let stack = CFState.Stacks?.[0];
@@ -112,16 +65,7 @@ export class AWSBaseStack {
const stackVersion = stack.Parameters?.find((x) => x.ParameterKey === 'Version')?.ParameterValue;
if (stack.StackStatus === 'CREATE_IN_PROGRESS') {
CloudRunnerLogger.log(
`Waiting up to ${stackWaitTimeSeconds}s for '${baseStackName}' CloudFormation creation to finish`,
);
await waitUntilStackCreateComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
describeStackInput,
);
await CF.waitFor('stackCreateComplete', describeStackInput).promise();
}
if (stackExists) {
@@ -129,7 +73,7 @@ export class AWSBaseStack {
if (parametersHash !== stackVersion) {
CloudRunnerLogger.log(`Attempting update of base stack`);
try {
await CF.send(new UpdateStackCommand(updateInput));
await CF.updateStack(updateInput).promise();
} catch (error: any) {
if (error['message'].includes('No updates are to be performed')) {
CloudRunnerLogger.log(`No updates are to be performed`);
@@ -149,16 +93,7 @@ export class AWSBaseStack {
);
}
if (stack.StackStatus === 'UPDATE_IN_PROGRESS') {
CloudRunnerLogger.log(
`Waiting up to ${stackWaitTimeSeconds}s for '${baseStackName}' CloudFormation update to finish`,
);
await waitUntilStackUpdateComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
describeStackInput,
);
await CF.waitFor('stackUpdateComplete', describeStackInput).promise();
}
}
CloudRunnerLogger.log('base stack is now ready');

View File

@@ -1,93 +0,0 @@
import { CloudFormation } from '@aws-sdk/client-cloudformation';
import { ECS } from '@aws-sdk/client-ecs';
import { Kinesis } from '@aws-sdk/client-kinesis';
import { CloudWatchLogs } from '@aws-sdk/client-cloudwatch-logs';
import { S3 } from '@aws-sdk/client-s3';
import { Input } from '../../..';
import CloudRunnerOptions from '../../options/cloud-runner-options';
export class AwsClientFactory {
private static cloudFormation: CloudFormation;
private static ecs: ECS;
private static kinesis: Kinesis;
private static cloudWatchLogs: CloudWatchLogs;
private static s3: S3;
private static getCredentials() {
// Explicitly provide credentials from environment variables for LocalStack compatibility
// LocalStack accepts any credentials, but the AWS SDK needs them to be explicitly set
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
if (accessKeyId && secretAccessKey) {
return {
accessKeyId,
secretAccessKey,
};
}
// Return undefined to let AWS SDK use default credential chain
return;
}
static getCloudFormation(): CloudFormation {
if (!this.cloudFormation) {
this.cloudFormation = new CloudFormation({
region: Input.region,
endpoint: CloudRunnerOptions.awsCloudFormationEndpoint,
credentials: AwsClientFactory.getCredentials(),
});
}
return this.cloudFormation;
}
static getECS(): ECS {
if (!this.ecs) {
this.ecs = new ECS({
region: Input.region,
endpoint: CloudRunnerOptions.awsEcsEndpoint,
credentials: AwsClientFactory.getCredentials(),
});
}
return this.ecs;
}
static getKinesis(): Kinesis {
if (!this.kinesis) {
this.kinesis = new Kinesis({
region: Input.region,
endpoint: CloudRunnerOptions.awsKinesisEndpoint,
credentials: AwsClientFactory.getCredentials(),
});
}
return this.kinesis;
}
static getCloudWatchLogs(): CloudWatchLogs {
if (!this.cloudWatchLogs) {
this.cloudWatchLogs = new CloudWatchLogs({
region: Input.region,
endpoint: CloudRunnerOptions.awsCloudWatchLogsEndpoint,
credentials: AwsClientFactory.getCredentials(),
});
}
return this.cloudWatchLogs;
}
static getS3(): S3 {
if (!this.s3) {
this.s3 = new S3({
region: Input.region,
endpoint: CloudRunnerOptions.awsS3Endpoint,
forcePathStyle: true,
credentials: AwsClientFactory.getCredentials(),
});
}
return this.s3;
}
}

View File

@@ -21,7 +21,6 @@ export class AWSCloudFormationTemplates {
public static getSecretDefinitionTemplate(p1: string, p2: string) {
return `
Secrets:
- Name: '${p1}'
ValueFrom: !Ref ${p2}Secret
`;

View File

@@ -1,15 +1,15 @@
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import { CloudFormation, DescribeStackEventsCommand } from '@aws-sdk/client-cloudformation';
import * as SDK from 'aws-sdk';
import * as core from '@actions/core';
import CloudRunner from '../../cloud-runner';
export class AWSError {
static async handleStackCreationFailure(error: any, CF: CloudFormation, taskDefStackName: string) {
static async handleStackCreationFailure(error: any, CF: SDK.CloudFormation, taskDefStackName: string) {
CloudRunnerLogger.log('aws error: ');
core.error(JSON.stringify(error, undefined, 4));
if (CloudRunner.buildParameters.cloudRunnerDebug) {
CloudRunnerLogger.log('Getting events and resources for task stack');
const events = (await CF.send(new DescribeStackEventsCommand({ StackName: taskDefStackName }))).StackEvents;
const events = (await CF.describeStackEvents({ StackName: taskDefStackName }).promise()).StackEvents;
CloudRunnerLogger.log(JSON.stringify(events, undefined, 4));
}
}

View File

@@ -1,13 +1,4 @@
import {
CloudFormation,
CreateStackCommand,
// eslint-disable-next-line import/named
CreateStackCommandInput,
DescribeStackResourcesCommand,
DescribeStacksCommand,
ListStacksCommand,
waitUntilStackCreateComplete,
} from '@aws-sdk/client-cloudformation';
import * as SDK from 'aws-sdk';
import CloudRunnerAWSTaskDef from './cloud-runner-aws-task-def';
import CloudRunnerSecret from '../../options/cloud-runner-secret';
import { AWSCloudFormationTemplates } from './aws-cloud-formation-templates';
@@ -18,17 +9,6 @@ import { CleanupCronFormation } from './cloud-formations/cleanup-cron-formation'
import CloudRunnerOptions from '../../options/cloud-runner-options';
import { TaskDefinitionFormation } from './cloud-formations/task-definition-formation';
const DEFAULT_STACK_WAIT_TIME_SECONDS = 600;
function getStackWaitTime(): number {
const overrideValue = Number(process.env.CLOUD_RUNNER_AWS_STACK_WAIT_TIME ?? '');
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}
export class AWSJobStack {
private baseStackName: string;
constructor(baseStackName: string) {
@@ -36,7 +16,7 @@ export class AWSJobStack {
}
public async setupCloudFormations(
CF: CloudFormation,
CF: SDK.CloudFormation,
buildGuid: string,
image: string,
entrypoint: string[],
@@ -139,7 +119,7 @@ export class AWSJobStack {
let previousStackExists = true;
while (previousStackExists) {
previousStackExists = false;
const stacks = await CF.send(new ListStacksCommand({}));
const stacks = await CF.listStacks().promise();
if (!stacks.StackSummaries) {
throw new Error('Faild to get stacks');
}
@@ -152,26 +132,17 @@ export class AWSJobStack {
}
}
}
const createStackInput: CreateStackCommandInput = {
const createStackInput: SDK.CloudFormation.CreateStackInput = {
StackName: taskDefStackName,
TemplateBody: taskDefCloudFormation,
Capabilities: ['CAPABILITY_IAM'],
Parameters: parameters,
};
try {
const stackWaitTimeSeconds = getStackWaitTime();
CloudRunnerLogger.log(
`Creating job aws formation ${taskDefStackName} (waiting up to ${stackWaitTimeSeconds}s for completion)`,
);
await CF.send(new CreateStackCommand(createStackInput));
await waitUntilStackCreateComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
{ StackName: taskDefStackName },
);
const describeStack = await CF.send(new DescribeStacksCommand({ StackName: taskDefStackName }));
CloudRunnerLogger.log(`Creating job aws formation ${taskDefStackName}`);
await CF.createStack(createStackInput).promise();
await CF.waitFor('stackCreateComplete', { StackName: taskDefStackName }).promise();
const describeStack = await CF.describeStacks({ StackName: taskDefStackName }).promise();
for (const parameter of parameters) {
if (!describeStack.Stacks?.[0].Parameters?.some((x) => x.ParameterKey === parameter.ParameterKey)) {
throw new Error(`Parameter ${parameter.ParameterKey} not found in stack`);
@@ -182,7 +153,7 @@ export class AWSJobStack {
throw error;
}
const createCleanupStackInput: CreateStackCommandInput = {
const createCleanupStackInput: SDK.CloudFormation.CreateStackInput = {
StackName: `${taskDefStackName}-cleanup`,
TemplateBody: CleanupCronFormation.formation,
Capabilities: ['CAPABILITY_IAM'],
@@ -212,7 +183,7 @@ export class AWSJobStack {
if (CloudRunnerOptions.useCleanupCron) {
try {
CloudRunnerLogger.log(`Creating job cleanup formation`);
await CF.send(new CreateStackCommand(createCleanupStackInput));
await CF.createStack(createCleanupStackInput).promise();
// await CF.waitFor('stackCreateComplete', { StackName: createCleanupStackInput.StackName }).promise();
} catch (error) {
@@ -222,15 +193,12 @@ export class AWSJobStack {
}
const taskDefResources = (
await CF.send(
new DescribeStackResourcesCommand({
StackName: taskDefStackName,
}),
)
await CF.describeStackResources({
StackName: taskDefStackName,
}).promise()
).StackResources;
const baseResources = (await CF.send(new DescribeStackResourcesCommand({ StackName: this.baseStackName })))
.StackResources;
const baseResources = (await CF.describeStackResources({ StackName: this.baseStackName }).promise()).StackResources;
return {
taskDefStackName,

View File

@@ -1,5 +1,4 @@
import { DescribeTasksCommand, RunTaskCommand, waitUntilTasksRunning } from '@aws-sdk/client-ecs';
import { DescribeStreamCommand, GetRecordsCommand, GetShardIteratorCommand } from '@aws-sdk/client-kinesis';
import * as AWS from 'aws-sdk';
import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
import * as core from '@actions/core';
import CloudRunnerAWSTaskDef from './cloud-runner-aws-task-def';
@@ -11,48 +10,11 @@ import { CommandHookService } from '../../services/hooks/command-hook-service';
import { FollowLogStreamService } from '../../services/core/follow-log-stream-service';
import CloudRunnerOptions from '../../options/cloud-runner-options';
import GitHub from '../../../github';
import { AwsClientFactory } from './aws-client-factory';
class AWSTaskRunner {
public static ECS: AWS.ECS;
public static Kinesis: AWS.Kinesis;
private static readonly encodedUnderscore = `$252F`;
/**
* Transform localhost endpoints to host.docker.internal for container environments.
* When LocalStack is used, ECS tasks run in Docker containers that need to reach
* LocalStack on the host machine via host.docker.internal.
*/
private static transformEndpointsForContainer(
environment: CloudRunnerEnvironmentVariable[],
): CloudRunnerEnvironmentVariable[] {
const endpointEnvironmentNames = new Set([
'AWS_S3_ENDPOINT',
'AWS_ENDPOINT',
'AWS_CLOUD_FORMATION_ENDPOINT',
'AWS_ECS_ENDPOINT',
'AWS_KINESIS_ENDPOINT',
'AWS_CLOUD_WATCH_LOGS_ENDPOINT',
'INPUT_AWSS3ENDPOINT',
'INPUT_AWSENDPOINT',
]);
return environment.map((x) => {
let value = x.value;
if (
typeof value === 'string' &&
endpointEnvironmentNames.has(x.name) &&
(value.startsWith('http://localhost') || value.startsWith('http://127.0.0.1'))
) {
// Replace localhost with host.docker.internal so ECS containers can access host services
value = value
.replace('http://localhost', 'http://host.docker.internal')
.replace('http://127.0.0.1', 'http://host.docker.internal');
CloudRunnerLogger.log(`AWS TaskRunner: Replaced localhost with host.docker.internal for ${x.name}: ${value}`);
}
return { name: x.name, value };
});
}
static async runTask(
taskDef: CloudRunnerAWSTaskDef,
environment: CloudRunnerEnvironmentVariable[],
@@ -70,9 +32,6 @@ class AWSTaskRunner {
const streamName =
taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'KinesisStream')?.PhysicalResourceId || '';
// Transform localhost endpoints for container environment
const transformedEnvironment = AWSTaskRunner.transformEndpointsForContainer(environment);
const runParameters = {
cluster,
taskDefinition,
@@ -81,7 +40,7 @@ class AWSTaskRunner {
containerOverrides: [
{
name: taskDef.taskDefStackName,
environment: transformedEnvironment,
environment,
command: ['-c', CommandHookService.ApplyHooksToCommands(commands, CloudRunner.buildParameters)],
},
],
@@ -101,7 +60,7 @@ class AWSTaskRunner {
throw new Error(`Container Overrides length must be at most 8192`);
}
const task = await AwsClientFactory.getECS().send(new RunTaskCommand(runParameters as any));
const task = await AWSTaskRunner.ECS.runTask(runParameters).promise();
const taskArn = task.tasks?.[0].taskArn || '';
CloudRunnerLogger.log('Cloud runner job is starting');
await AWSTaskRunner.waitUntilTaskRunning(taskArn, cluster);
@@ -124,13 +83,9 @@ class AWSTaskRunner {
let containerState;
let taskData;
while (exitCode === undefined) {
await new Promise((resolve) => setTimeout(resolve, 10000));
await new Promise((resolve) => resolve(10000));
taskData = await AWSTaskRunner.describeTasks(cluster, taskArn);
const containers = taskData?.containers as any[] | undefined;
if (!containers || containers.length === 0) {
continue;
}
containerState = containers[0];
containerState = taskData.containers?.[0];
exitCode = containerState?.exitCode;
}
CloudRunnerLogger.log(`Container State: ${JSON.stringify(containerState, undefined, 4)}`);
@@ -153,20 +108,15 @@ class AWSTaskRunner {
private static async waitUntilTaskRunning(taskArn: string, cluster: string) {
try {
await waitUntilTasksRunning(
{
client: AwsClientFactory.getECS(),
maxWaitTime: 300,
minDelay: 5,
maxDelay: 30,
},
{ tasks: [taskArn], cluster },
);
await AWSTaskRunner.ECS.waitFor('tasksRunning', { tasks: [taskArn], cluster }).promise();
} catch (error_) {
const error = error_ as Error;
await new Promise((resolve) => setTimeout(resolve, 3000));
const taskAfterError = await AWSTaskRunner.describeTasks(cluster, taskArn);
CloudRunnerLogger.log(`Cloud runner job has ended ${taskAfterError?.containers?.[0]?.lastStatus}`);
CloudRunnerLogger.log(
`Cloud runner job has ended ${
(await AWSTaskRunner.describeTasks(cluster, taskArn)).containers?.[0].lastStatus
}`,
);
core.setFailed(error);
core.error(error);
@@ -174,31 +124,14 @@ class AWSTaskRunner {
}
static async describeTasks(clusterName: string, taskArn: string) {
const maxAttempts = 10;
let delayMs = 1000;
const maxDelayMs = 60000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const tasks = await AwsClientFactory.getECS().send(
new DescribeTasksCommand({ cluster: clusterName, tasks: [taskArn] }),
);
if (tasks.tasks?.[0]) {
return tasks.tasks?.[0];
}
throw new Error('No task found');
} catch (error: any) {
const isThrottle = error?.name === 'ThrottlingException' || /rate exceeded/i.test(String(error?.message));
if (!isThrottle || attempt === maxAttempts) {
throw error;
}
const jitterMs = Math.floor(Math.random() * Math.min(1000, delayMs));
const sleepMs = delayMs + jitterMs;
CloudRunnerLogger.log(
`AWS throttled DescribeTasks (attempt ${attempt}/${maxAttempts}), backing off ${sleepMs}ms (${delayMs} + jitter ${jitterMs})`,
);
await new Promise((r) => setTimeout(r, sleepMs));
delayMs = Math.min(delayMs * 2, maxDelayMs);
}
const tasks = await AWSTaskRunner.ECS.describeTasks({
cluster: clusterName,
tasks: [taskArn],
}).promise();
if (tasks.tasks?.[0]) {
return tasks.tasks?.[0];
} else {
throw new Error('No task found');
}
}
@@ -219,9 +152,6 @@ class AWSTaskRunner {
await new Promise((resolve) => setTimeout(resolve, 1500));
const taskData = await AWSTaskRunner.describeTasks(clusterName, taskArn);
({ timestamp, shouldReadLogs } = AWSTaskRunner.checkStreamingShouldContinue(taskData, timestamp, shouldReadLogs));
if (taskData?.lastStatus !== 'RUNNING') {
await new Promise((resolve) => setTimeout(resolve, 3500));
}
({ iterator, shouldReadLogs, output, shouldCleanup } = await AWSTaskRunner.handleLogStreamIteration(
iterator,
shouldReadLogs,
@@ -239,22 +169,9 @@ class AWSTaskRunner {
output: string,
shouldCleanup: boolean,
) {
let records: any;
try {
records = await AwsClientFactory.getKinesis().send(new GetRecordsCommand({ ShardIterator: iterator }));
} catch (error: any) {
const isThrottle = error?.name === 'ThrottlingException' || /rate exceeded/i.test(String(error?.message));
if (isThrottle) {
const baseBackoffMs = 1000;
const jitterMs = Math.floor(Math.random() * 1000);
const sleepMs = baseBackoffMs + jitterMs;
CloudRunnerLogger.log(`AWS throttled GetRecords, backing off ${sleepMs}ms (1000 + jitter ${jitterMs})`);
await new Promise((r) => setTimeout(r, sleepMs));
return { iterator, shouldReadLogs, output, shouldCleanup };
}
throw error;
}
const records = await AWSTaskRunner.Kinesis.getRecords({
ShardIterator: iterator,
}).promise();
iterator = records.NextShardIterator || '';
({ shouldReadLogs, output, shouldCleanup } = AWSTaskRunner.logRecords(
records,
@@ -267,7 +184,7 @@ class AWSTaskRunner {
return { iterator, shouldReadLogs, output, shouldCleanup };
}
private static checkStreamingShouldContinue(taskData: any, timestamp: number, shouldReadLogs: boolean) {
private static checkStreamingShouldContinue(taskData: AWS.ECS.Task, timestamp: number, shouldReadLogs: boolean) {
if (taskData?.lastStatus === 'UNKNOWN') {
CloudRunnerLogger.log('## Cloud runner job unknwon');
}
@@ -287,17 +204,15 @@ class AWSTaskRunner {
}
private static logRecords(
records: any,
records: AWS.Kinesis.GetRecordsOutput,
iterator: string,
shouldReadLogs: boolean,
output: string,
shouldCleanup: boolean,
) {
if ((records.Records ?? []).length > 0 && iterator) {
for (const record of records.Records ?? []) {
const json = JSON.parse(
zlib.gunzipSync(Buffer.from(record.Data as unknown as string, 'base64')).toString('utf8'),
);
if (records.Records.length > 0 && iterator) {
for (const record of records.Records) {
const json = JSON.parse(zlib.gunzipSync(Buffer.from(record.Data as string, 'base64')).toString('utf8'));
if (json.messageType === 'DATA_MESSAGE') {
for (const logEvent of json.logEvents) {
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
@@ -315,19 +230,19 @@ class AWSTaskRunner {
}
private static async getLogStream(kinesisStreamName: string) {
return await AwsClientFactory.getKinesis().send(new DescribeStreamCommand({ StreamName: kinesisStreamName }));
return await AWSTaskRunner.Kinesis.describeStream({
StreamName: kinesisStreamName,
}).promise();
}
private static async getLogIterator(stream: any) {
private static async getLogIterator(stream: AWS.Kinesis.DescribeStreamOutput) {
return (
(
await AwsClientFactory.getKinesis().send(
new GetShardIteratorCommand({
ShardIteratorType: 'TRIM_HORIZON',
StreamName: stream.StreamDescription?.StreamName ?? '',
ShardId: stream.StreamDescription?.Shards?.[0]?.ShardId || '',
}),
)
await AWSTaskRunner.Kinesis.getShardIterator({
ShardIteratorType: 'TRIM_HORIZON',
StreamName: stream.StreamDescription.StreamName,
ShardId: stream.StreamDescription.Shards[0].ShardId,
}).promise()
).ShardIterator || ''
);
}

View File

@@ -1,9 +1,6 @@
import CloudRunner from '../../../cloud-runner';
export class TaskDefinitionFormation {
public static readonly description: string = `Game CI Cloud Runner Task Stack`;
public static get formation(): string {
return `AWSTemplateFormatVersion: 2010-09-09
public static readonly formation: string = `AWSTemplateFormatVersion: 2010-09-09
Description: ${TaskDefinitionFormation.description}
Parameters:
EnvironmentName:
@@ -29,11 +26,11 @@ Parameters:
Default: 80
Description: What port number the application inside the docker container is binding to
ContainerCpu:
Default: ${CloudRunner.buildParameters.containerCpu}
Default: 1024
Type: Number
Description: How much CPU to give the container. 1024 is 1 CPU
ContainerMemory:
Default: ${CloudRunner.buildParameters.containerMemory}
Default: 4096
Type: Number
Description: How much memory in megabytes to give the container
BUILDGUID:
@@ -95,7 +92,7 @@ Resources:
EFSVolumeConfiguration:
FilesystemId:
'Fn::ImportValue': !Sub '${'${EnvironmentName}'}:EfsFileStorageId'
TransitEncryption: DISABLED
TransitEncryption: ENABLED
RequiresCompatibilities:
- FARGATE
ExecutionRoleArn:
@@ -127,7 +124,8 @@ Resources:
- SourceVolume: efs-data
ContainerPath: !Ref EFSMountDirectory
ReadOnly: false
# template secrets p3 - container def
Secrets:
# template secrets p3 - container def
LogConfiguration:
LogDriver: awslogs
Options:
@@ -137,7 +135,6 @@ Resources:
DependsOn:
- LogGroup
`;
}
public static streamLogs = `
SubscriptionFilter:
Type: 'AWS::Logs::SubscriptionFilter'

View File

@@ -1,10 +1,9 @@
// eslint-disable-next-line import/named
import { StackResource } from '@aws-sdk/client-cloudformation';
import * as AWS from 'aws-sdk';
class CloudRunnerAWSTaskDef {
public taskDefStackName!: string;
public taskDefCloudFormation!: string;
public taskDefResources: StackResource[] | undefined;
public baseResources: StackResource[] | undefined;
public taskDefResources: AWS.CloudFormation.StackResources | undefined;
public baseResources: AWS.CloudFormation.StackResources | undefined;
}
export default CloudRunnerAWSTaskDef;

View File

@@ -1,4 +1,4 @@
import { CloudFormation, DeleteStackCommand, waitUntilStackDeleteComplete } from '@aws-sdk/client-cloudformation';
import * as SDK from 'aws-sdk';
import CloudRunnerSecret from '../../options/cloud-runner-secret';
import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
import CloudRunnerAWSTaskDef from './cloud-runner-aws-task-def';
@@ -14,19 +14,6 @@ import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { TaskService } from './services/task-service';
import CloudRunnerOptions from '../../options/cloud-runner-options';
import { AwsClientFactory } from './aws-client-factory';
import ResourceTracking from '../../services/core/resource-tracking';
const DEFAULT_STACK_WAIT_TIME_SECONDS = 600;
function getStackWaitTime(): number {
const overrideValue = Number(process.env.CLOUD_RUNNER_AWS_STACK_WAIT_TIME ?? '');
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}
class AWSBuildEnvironment implements ProviderInterface {
private baseStackName: string;
@@ -70,6 +57,8 @@ class AWSBuildEnvironment implements ProviderInterface {
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
@@ -88,7 +77,7 @@ class AWSBuildEnvironment implements ProviderInterface {
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
process.env.AWS_REGION = Input.region;
const CF = AwsClientFactory.getCloudFormation();
const CF = new SDK.CloudFormation();
await new AwsBaseStack(this.baseStackName).setupBaseStack(CF);
}
@@ -102,11 +91,10 @@ class AWSBuildEnvironment implements ProviderInterface {
secrets: CloudRunnerSecret[],
): Promise<string> {
process.env.AWS_REGION = Input.region;
ResourceTracking.logAllocationSummary('aws workflow');
await ResourceTracking.logDiskUsageSnapshot('aws workflow (host)');
AwsClientFactory.getECS();
const CF = AwsClientFactory.getCloudFormation();
AwsClientFactory.getKinesis();
const ECS = new SDK.ECS();
const CF = new SDK.CloudFormation();
AwsTaskRunner.ECS = ECS;
AwsTaskRunner.Kinesis = new SDK.Kinesis();
CloudRunnerLogger.log(`AWS Region: ${CF.config.region}`);
const entrypoint = ['/bin/sh'];
const startTimeMs = Date.now();
@@ -143,32 +131,23 @@ class AWSBuildEnvironment implements ProviderInterface {
}
}
async cleanupResources(CF: CloudFormation, taskDef: CloudRunnerAWSTaskDef) {
const stackWaitTimeSeconds = getStackWaitTime();
CloudRunnerLogger.log(`Cleanup starting (waiting up to ${stackWaitTimeSeconds}s for stack deletion)`);
await CF.send(new DeleteStackCommand({ StackName: taskDef.taskDefStackName }));
async cleanupResources(CF: SDK.CloudFormation, taskDef: CloudRunnerAWSTaskDef) {
CloudRunnerLogger.log('Cleanup starting');
await CF.deleteStack({
StackName: taskDef.taskDefStackName,
}).promise();
if (CloudRunnerOptions.useCleanupCron) {
await CF.send(new DeleteStackCommand({ StackName: `${taskDef.taskDefStackName}-cleanup` }));
await CF.deleteStack({
StackName: `${taskDef.taskDefStackName}-cleanup`,
}).promise();
}
await waitUntilStackDeleteComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
{
StackName: taskDef.taskDefStackName,
},
);
await waitUntilStackDeleteComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
{
StackName: `${taskDef.taskDefStackName}-cleanup`,
},
);
await CF.waitFor('stackDeleteComplete', {
StackName: taskDef.taskDefStackName,
}).promise();
await CF.waitFor('stackDeleteComplete', {
StackName: `${taskDef.taskDefStackName}-cleanup`,
}).promise();
CloudRunnerLogger.log(`Deleted Stack: ${taskDef.taskDefStackName}`);
CloudRunnerLogger.log('Cleanup complete');
}

View File

@@ -1,10 +1,7 @@
import { DeleteStackCommand, DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation';
import { DeleteLogGroupCommand } from '@aws-sdk/client-cloudwatch-logs';
import { StopTaskCommand } from '@aws-sdk/client-ecs';
import AWS from 'aws-sdk';
import Input from '../../../../input';
import CloudRunnerLogger from '../../../services/core/cloud-runner-logger';
import { TaskService } from './task-service';
import { AwsClientFactory } from '../aws-client-factory';
export class GarbageCollectionService {
static isOlderThan1day(date: Date) {
@@ -15,9 +12,9 @@ export class GarbageCollectionService {
public static async cleanup(deleteResources = false, OneDayOlderOnly: boolean = false) {
process.env.AWS_REGION = Input.region;
const CF = AwsClientFactory.getCloudFormation();
const ecs = AwsClientFactory.getECS();
const cwl = AwsClientFactory.getCloudWatchLogs();
const CF = new AWS.CloudFormation();
const ecs = new AWS.ECS();
const cwl = new AWS.CloudWatchLogs();
const taskDefinitionsInUse = new Array();
const tasks = await TaskService.getTasks();
@@ -26,14 +23,14 @@ export class GarbageCollectionService {
taskDefinitionsInUse.push(taskElement.taskDefinitionArn);
if (deleteResources && (!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(taskElement.createdAt!))) {
CloudRunnerLogger.log(`Stopping task ${taskElement.containers?.[0].name}`);
await ecs.send(new StopTaskCommand({ task: taskElement.taskArn || '', cluster: element }));
await ecs.stopTask({ task: taskElement.taskArn || '', cluster: element }).promise();
}
}
const jobStacks = await TaskService.getCloudFormationJobStacks();
for (const element of jobStacks) {
if (
(await CF.send(new DescribeStackResourcesCommand({ StackName: element.StackName }))).StackResources?.some(
(await CF.describeStackResources({ StackName: element.StackName }).promise()).StackResources?.some(
(x) => x.ResourceType === 'AWS::ECS::TaskDefinition' && taskDefinitionsInUse.includes(x.PhysicalResourceId),
)
) {
@@ -42,10 +39,7 @@ export class GarbageCollectionService {
return;
}
if (
deleteResources &&
(!OneDayOlderOnly || (element.CreationTime && GarbageCollectionService.isOlderThan1day(element.CreationTime)))
) {
if (deleteResources && (!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(element.CreationTime))) {
if (element.StackName === 'game-ci' || element.TemplateDescription === 'Game-CI base stack') {
CloudRunnerLogger.log(`Skipping ${element.StackName} ignore list`);
@@ -53,7 +47,8 @@ export class GarbageCollectionService {
}
CloudRunnerLogger.log(`Deleting ${element.StackName}`);
await CF.send(new DeleteStackCommand({ StackName: element.StackName }));
const deleteStackInput: AWS.CloudFormation.DeleteStackInput = { StackName: element.StackName };
await CF.deleteStack(deleteStackInput).promise();
}
}
const logGroups = await TaskService.getLogGroups();
@@ -63,7 +58,7 @@ export class GarbageCollectionService {
(!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(new Date(element.creationTime!)))
) {
CloudRunnerLogger.log(`Deleting ${element.logGroupName}`);
await cwl.send(new DeleteLogGroupCommand({ logGroupName: element.logGroupName || '' }));
await cwl.deleteLogGroup({ logGroupName: element.logGroupName || '' }).promise();
}
}

View File

@@ -1,22 +1,12 @@
import {
DescribeStackResourcesCommand,
DescribeStacksCommand,
ListStacksCommand,
} from '@aws-sdk/client-cloudformation';
import type { StackSummary } from '@aws-sdk/client-cloudformation';
// eslint-disable-next-line import/named
import { DescribeLogGroupsCommand, DescribeLogGroupsCommandInput } from '@aws-sdk/client-cloudwatch-logs';
import type { LogGroup } from '@aws-sdk/client-cloudwatch-logs';
import { DescribeTasksCommand, ListClustersCommand, ListTasksCommand } from '@aws-sdk/client-ecs';
import type { Task } from '@aws-sdk/client-ecs';
import { ListObjectsV2Command } from '@aws-sdk/client-s3';
import AWS from 'aws-sdk';
import Input from '../../../../input';
import CloudRunnerLogger from '../../../services/core/cloud-runner-logger';
import { BaseStackFormation } from '../cloud-formations/base-stack-formation';
import AwsTaskRunner from '../aws-task-runner';
import { ListObjectsRequest } from 'aws-sdk/clients/s3';
import CloudRunner from '../../../cloud-runner';
import { AwsClientFactory } from '../aws-client-factory';
import SharedWorkspaceLocking from '../../../services/core/shared-workspace-locking';
import { StackSummaries } from 'aws-sdk/clients/cloudformation';
import { LogGroups } from 'aws-sdk/clients/cloudwatchlogs';
export class TaskService {
static async watch() {
@@ -29,25 +19,21 @@ export class TaskService {
return output;
}
public static async getCloudFormationJobStacks(): Promise<StackSummary[]> {
const result: StackSummary[] = [];
public static async getCloudFormationJobStacks() {
const result: StackSummaries = [];
CloudRunnerLogger.log(``);
CloudRunnerLogger.log(`List Cloud Formation Stacks`);
process.env.AWS_REGION = Input.region;
const CF = AwsClientFactory.getCloudFormation();
const CF = new AWS.CloudFormation();
const stacks =
(await CF.send(new ListStacksCommand({}))).StackSummaries?.filter(
(await CF.listStacks().promise()).StackSummaries?.filter(
(_x) =>
_x.StackStatus !== 'DELETE_COMPLETE' && _x.TemplateDescription !== BaseStackFormation.baseStackDecription,
) || [];
CloudRunnerLogger.log(``);
CloudRunnerLogger.log(`Cloud Formation Stacks ${stacks.length}`);
for (const element of stacks) {
if (!element.CreationTime) {
CloudRunnerLogger.log(`${element.StackName} due to undefined CreationTime`);
}
const ageDate: Date = new Date(Date.now() - (element.CreationTime?.getTime() ?? 0));
const ageDate: Date = new Date(Date.now() - element.CreationTime.getTime());
CloudRunnerLogger.log(
`Task Stack ${element.StackName} - Age D${Math.floor(
@@ -57,18 +43,14 @@ export class TaskService {
result.push(element);
}
const baseStacks =
(await CF.send(new ListStacksCommand({}))).StackSummaries?.filter(
(await CF.listStacks().promise()).StackSummaries?.filter(
(_x) =>
_x.StackStatus !== 'DELETE_COMPLETE' && _x.TemplateDescription === BaseStackFormation.baseStackDecription,
) || [];
CloudRunnerLogger.log(``);
CloudRunnerLogger.log(`Base Stacks ${baseStacks.length}`);
for (const element of baseStacks) {
if (!element.CreationTime) {
CloudRunnerLogger.log(`${element.StackName} due to undefined CreationTime`);
}
const ageDate: Date = new Date(Date.now() - (element.CreationTime?.getTime() ?? 0));
const ageDate: Date = new Date(Date.now() - element.CreationTime.getTime());
CloudRunnerLogger.log(
`Task Stack ${element.StackName} - Age D${Math.floor(
@@ -81,35 +63,23 @@ export class TaskService {
return result;
}
public static async getTasks(): Promise<{ taskElement: Task; element: string }[]> {
const result: { taskElement: Task; element: string }[] = [];
public static async getTasks() {
const result: { taskElement: AWS.ECS.Task; element: string }[] = [];
CloudRunnerLogger.log(``);
CloudRunnerLogger.log(`List Tasks`);
process.env.AWS_REGION = Input.region;
const ecs = AwsClientFactory.getECS();
const clusters: string[] = [];
{
let nextToken: string | undefined;
do {
const clusterResponse = await ecs.send(new ListClustersCommand({ nextToken }));
clusters.push(...(clusterResponse.clusterArns ?? []));
nextToken = clusterResponse.nextToken;
} while (nextToken);
}
const ecs = new AWS.ECS();
const clusters = (await ecs.listClusters().promise()).clusterArns || [];
CloudRunnerLogger.log(`Task Clusters ${clusters.length}`);
for (const element of clusters) {
const taskArns: string[] = [];
{
let nextToken: string | undefined;
do {
const taskResponse = await ecs.send(new ListTasksCommand({ cluster: element, nextToken }));
taskArns.push(...(taskResponse.taskArns ?? []));
nextToken = taskResponse.nextToken;
} while (nextToken);
}
if (taskArns.length > 0) {
const describeInput = { tasks: taskArns, cluster: element };
const describeList = (await ecs.send(new DescribeTasksCommand(describeInput))).tasks || [];
const input: AWS.ECS.ListTasksRequest = {
cluster: element,
};
const list = (await ecs.listTasks(input).promise()).taskArns || [];
if (list.length > 0) {
const describeInput: AWS.ECS.DescribeTasksRequest = { tasks: list, cluster: element };
const describeList = (await ecs.describeTasks(describeInput).promise()).tasks || [];
if (describeList.length === 0) {
CloudRunnerLogger.log(`No Tasks`);
continue;
@@ -119,6 +89,8 @@ export class TaskService {
if (taskElement === undefined) {
continue;
}
taskElement.overrides = {};
taskElement.attachments = [];
if (taskElement.createdAt === undefined) {
CloudRunnerLogger.log(`Skipping ${taskElement.taskDefinitionArn} no createdAt date`);
continue;
@@ -133,51 +105,37 @@ export class TaskService {
}
public static async awsDescribeJob(job: string) {
process.env.AWS_REGION = Input.region;
const CF = AwsClientFactory.getCloudFormation();
try {
const stack =
(await CF.send(new ListStacksCommand({}))).StackSummaries?.find((_x) => _x.StackName === job) || undefined;
const stackInfo = (await CF.send(new DescribeStackResourcesCommand({ StackName: job }))) || undefined;
const stackInfo2 = (await CF.send(new DescribeStacksCommand({ StackName: job }))) || undefined;
if (stack === undefined) {
throw new Error('stack not defined');
}
if (!stack.CreationTime) {
CloudRunnerLogger.log(`${stack.StackName} due to undefined CreationTime`);
}
const ageDate: Date = new Date(Date.now() - (stack.CreationTime?.getTime() ?? 0));
const message = `
const CF = new AWS.CloudFormation();
const stack = (await CF.listStacks().promise()).StackSummaries?.find((_x) => _x.StackName === job) || undefined;
const stackInfo = (await CF.describeStackResources({ StackName: job }).promise()) || undefined;
const stackInfo2 = (await CF.describeStacks({ StackName: job }).promise()) || undefined;
if (stack === undefined) {
throw new Error('stack not defined');
}
const ageDate: Date = new Date(Date.now() - stack.CreationTime.getTime());
const message = `
Task Stack ${stack.StackName}
Age D${Math.floor(ageDate.getHours() / 24)} H${ageDate.getHours()} M${ageDate.getMinutes()}
${JSON.stringify(stack, undefined, 4)}
${JSON.stringify(stackInfo, undefined, 4)}
${JSON.stringify(stackInfo2, undefined, 4)}
`;
CloudRunnerLogger.log(message);
CloudRunnerLogger.log(message);
return message;
} catch (error) {
CloudRunnerLogger.error(
`Failed to describe job ${job}: ${error instanceof Error ? error.message : String(error)}`,
);
throw error;
}
return message;
}
public static async getLogGroups(): Promise<LogGroup[]> {
const result: LogGroup[] = [];
public static async getLogGroups() {
const result: LogGroups = [];
process.env.AWS_REGION = Input.region;
const cwl = AwsClientFactory.getCloudWatchLogs();
let logStreamInput: DescribeLogGroupsCommandInput = {
const ecs = new AWS.CloudWatchLogs();
let logStreamInput: AWS.CloudWatchLogs.DescribeLogGroupsRequest = {
/* logGroupNamePrefix: 'game-ci' */
};
let logGroupsDescribe = await cwl.send(new DescribeLogGroupsCommand(logStreamInput));
let logGroupsDescribe = await ecs.describeLogGroups(logStreamInput).promise();
const logGroups = logGroupsDescribe.logGroups || [];
while (logGroupsDescribe.nextToken) {
logStreamInput = {
/* logGroupNamePrefix: 'game-ci',*/
nextToken: logGroupsDescribe.nextToken,
};
logGroupsDescribe = await cwl.send(new DescribeLogGroupsCommand(logStreamInput));
logStreamInput = { /* logGroupNamePrefix: 'game-ci',*/ nextToken: logGroupsDescribe.nextToken };
logGroupsDescribe = await ecs.describeLogGroups(logStreamInput).promise();
logGroups.push(...(logGroupsDescribe?.logGroups || []));
}
@@ -199,22 +157,14 @@ export class TaskService {
return result;
}
public static async getLocks(): Promise<Array<{ Key: string }>> {
public static async getLocks() {
process.env.AWS_REGION = Input.region;
if (CloudRunner.buildParameters.storageProvider === 'rclone') {
// eslint-disable-next-line no-unused-vars
type ListObjectsFunction = (prefix: string) => Promise<string[]>;
const objects = await (SharedWorkspaceLocking as unknown as { listObjects: ListObjectsFunction }).listObjects('');
return objects.map((x: string) => ({ Key: x }));
}
const s3 = AwsClientFactory.getS3();
const listRequest = {
const s3 = new AWS.S3();
const listRequest: ListObjectsRequest = {
Bucket: CloudRunner.buildParameters.awsStackName,
};
const results = await s3.listObjects(listRequest).promise();
const results = await s3.send(new ListObjectsV2Command(listRequest));
return (results.Contents || []).map((object) => ({ Key: object.Key || '' }));
return results.Contents || [];
}
}

View File

@@ -41,6 +41,7 @@ class LocalDockerCloudRunner implements ProviderInterface {
return new Promise((result) => result(``));
}
async cleanupWorkflow(
buildGuid: string,
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
@@ -91,33 +92,8 @@ class LocalDockerCloudRunner implements ProviderInterface {
for (const x of secrets) {
content.push({ name: x.EnvironmentVariable, value: x.ParameterValue });
}
// Replace localhost with host.docker.internal for LocalStack endpoints (similar to K8s)
// This allows Docker containers to access LocalStack running on the host
const endpointEnvironmentNames = new Set([
'AWS_S3_ENDPOINT',
'AWS_ENDPOINT',
'AWS_CLOUD_FORMATION_ENDPOINT',
'AWS_ECS_ENDPOINT',
'AWS_KINESIS_ENDPOINT',
'AWS_CLOUD_WATCH_LOGS_ENDPOINT',
'INPUT_AWSS3ENDPOINT',
'INPUT_AWSENDPOINT',
]);
for (const x of environment) {
let value = x.value;
if (
typeof value === 'string' &&
endpointEnvironmentNames.has(x.name) &&
(value.startsWith('http://localhost') || value.startsWith('http://127.0.0.1'))
) {
// Replace localhost with host.docker.internal so containers can access host services
value = value
.replace('http://localhost', 'http://host.docker.internal')
.replace('http://127.0.0.1', 'http://host.docker.internal');
CloudRunnerLogger.log(`Replaced localhost with host.docker.internal for ${x.name}: ${value}`);
}
content.push({ name: x.name, value });
content.push({ name: x.name, value: x.value });
}
// if (this.buildParameters?.cloudRunnerIntegrationTests) {
@@ -137,22 +113,14 @@ class LocalDockerCloudRunner implements ProviderInterface {
// core.info(JSON.stringify({ workspace, actionFolder, ...this.buildParameters, ...content }, undefined, 4));
const entrypointFilePath = `start.sh`;
// Use #!/bin/sh for POSIX compatibility (Alpine-based images like rclone/rclone don't have bash)
const fileContents = `#!/bin/sh
const fileContents = `#!/bin/bash
set -e
mkdir -p /github/workspace/cloud-runner-cache
mkdir -p /data/cache
cp -a /github/workspace/cloud-runner-cache/. ${sharedFolder}
${CommandHookService.ApplyHooksToCommands(commands, this.buildParameters)}
# Only copy cache directory, exclude retained workspaces to avoid running out of disk space
if [ -d "${sharedFolder}cache" ]; then
cp -a ${sharedFolder}cache/. /github/workspace/cloud-runner-cache/cache/ || true
fi
# Copy test files from /data/ root to workspace for test assertions
# This allows tests to write files to /data/ and have them available in the workspace
find ${sharedFolder} -maxdepth 1 -type f -name "test-*" -exec cp -a {} /github/workspace/cloud-runner-cache/ \\; || true
cp -a ${sharedFolder}. /github/workspace/cloud-runner-cache/
`;
writeFileSync(`${workspace}/${entrypointFilePath}`, fileContents, {
flag: 'w',
@@ -165,7 +133,7 @@ find ${sharedFolder} -maxdepth 1 -type f -name "test-*" -exec cp -a {} /github/w
if (fs.existsSync(`${workspace}/cloud-runner-cache`)) {
await CloudRunnerSystem.Run(`ls ${workspace}/cloud-runner-cache && du -sh ${workspace}/cloud-runner-cache`);
}
const exitCode = await Docker.run(
await Docker.run(
image,
{ workspace, actionFolder, ...this.buildParameters },
false,
@@ -182,14 +150,9 @@ find ${sharedFolder} -maxdepth 1 -type f -name "test-*" -exec cp -a {} /github/w
},
},
true,
false,
);
// Docker doesn't exit on fail now so adding this to ensure behavior is unchanged
// TODO: Is there a helpful way to consume the exit code or is it best to except
if (exitCode !== 0) {
throw new Error(`Build failed with exit code ${exitCode}`);
}
return myOutput;
}
}

View File

@@ -14,18 +14,12 @@ import { CoreV1Api } from '@kubernetes/client-node';
import CloudRunner from '../../cloud-runner';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { RemoteClientLogger } from '../../remote-client/remote-client-logger';
import { KubernetesRole } from './kubernetes-role';
import { CloudRunnerSystem } from '../../services/core/cloud-runner-system';
import ResourceTracking from '../../services/core/resource-tracking';
class Kubernetes implements ProviderInterface {
public static Instance: Kubernetes;
public kubeConfig!: k8s.KubeConfig;
public kubeClient!: k8s.CoreV1Api;
public kubeClientApps!: k8s.AppsV1Api;
public kubeClientBatch!: k8s.BatchV1Api;
public rbacAuthorizationV1Api!: k8s.RbacAuthorizationV1Api;
public buildGuid: string = '';
public buildParameters!: BuildParameters;
public pvcName: string = '';
@@ -36,37 +30,18 @@ class Kubernetes implements ProviderInterface {
public containerName: string = '';
public cleanupCronJobName: string = '';
public serviceAccountName: string = '';
public ip: string = '';
// eslint-disable-next-line no-unused-vars
constructor(buildParameters: BuildParameters) {
Kubernetes.Instance = this;
this.kubeConfig = new k8s.KubeConfig();
this.kubeConfig.loadFromDefault();
this.kubeClient = this.kubeConfig.makeApiClient(k8s.CoreV1Api);
this.kubeClientApps = this.kubeConfig.makeApiClient(k8s.AppsV1Api);
this.kubeClientBatch = this.kubeConfig.makeApiClient(k8s.BatchV1Api);
this.rbacAuthorizationV1Api = this.kubeConfig.makeApiClient(k8s.RbacAuthorizationV1Api);
this.namespace = buildParameters.containerNamespace ? buildParameters.containerNamespace : 'default';
this.namespace = 'default';
CloudRunnerLogger.log('Loaded default Kubernetes configuration for this environment');
}
async PushLogUpdate(logs: string) {
// push logs to nginx file server via 'LOG_SERVICE_IP' env var
const ip = process.env[`LOG_SERVICE_IP`];
if (ip === undefined) {
RemoteClientLogger.logWarning(`LOG_SERVICE_IP not set, skipping log push`);
return;
}
const url = `http://${ip}/api/log`;
RemoteClientLogger.log(`Pushing logs to ${url}`);
// logs to base64
logs = Buffer.from(logs).toString('base64');
const response = await CloudRunnerSystem.Run(`curl -X POST -d "${logs}" ${url}`, false, true);
RemoteClientLogger.log(`Pushed logs to ${url} ${response}`);
}
async listResources(): Promise<ProviderResource[]> {
const pods = await this.kubeClient.listNamespacedPod(this.namespace);
const serviceAccounts = await this.kubeClient.listNamespacedServiceAccount(this.namespace);
@@ -138,15 +113,11 @@ class Kubernetes implements ProviderInterface {
): Promise<string> {
try {
CloudRunnerLogger.log('Cloud Runner K8s workflow!');
ResourceTracking.logAllocationSummary('k8s workflow');
await ResourceTracking.logDiskUsageSnapshot('k8s workflow (host)');
await ResourceTracking.logK3dNodeDiskUsage('k8s workflow (before job)');
// Setup
const id =
BuildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(this.buildParameters)
? CloudRunner.lockedWorkspace
: this.buildParameters.buildGuid;
const id = BuildParameters.shouldUseRetainedWorkspaceMode(this.buildParameters)
? CloudRunner.lockedWorkspace
: this.buildParameters.buildGuid;
this.pvcName = `unity-builder-pvc-${id}`;
await KubernetesStorage.createPersistentVolumeClaim(
this.buildParameters,
@@ -159,134 +130,17 @@ class Kubernetes implements ProviderInterface {
this.jobName = `unity-builder-job-${this.buildGuid}`;
this.containerName = `main`;
await KubernetesSecret.createSecret(secrets, this.secretName, this.namespace, this.kubeClient);
// For tests, clean up old images before creating job to free space for image pull
// IMPORTANT: Preserve the Unity image to avoid re-pulling it
if (process.env['cloudRunnerTests'] === 'true') {
try {
CloudRunnerLogger.log('Cleaning up old images in k3d node before pulling new image...');
const { CloudRunnerSystem: CloudRunnerSystemModule } = await import(
'../../services/core/cloud-runner-system'
);
// Aggressive cleanup: remove stopped containers and non-Unity images
// IMPORTANT: Preserve Unity images (unityci/editor) to avoid re-pulling the 3.9GB image
const K3D_NODE_CONTAINERS = ['k3d-unity-builder-agent-0', 'k3d-unity-builder-server-0'];
const cleanupCommands: string[] = [];
for (const NODE of K3D_NODE_CONTAINERS) {
// Remove all stopped containers (this frees runtime space but keeps images)
cleanupCommands.push(
`docker exec ${NODE} sh -c "crictl rm --all 2>/dev/null || true" || true`,
`docker exec ${NODE} sh -c "for img in $(crictl images -q 2>/dev/null); do repo=$(crictl inspecti $img --format '{{.repo}}' 2>/dev/null || echo ''); if echo "$repo" | grep -qvE 'unityci/editor|unity'; then crictl rmi $img 2>/dev/null || true; fi; done" || true`,
`docker exec ${NODE} sh -c "crictl rmi --prune 2>/dev/null || true" || true`,
);
}
for (const cmd of cleanupCommands) {
try {
await CloudRunnerSystemModule.Run(cmd, true, true);
} catch (cmdError) {
// Ignore individual command failures - cleanup is best effort
CloudRunnerLogger.log(`Cleanup command failed (non-fatal): ${cmdError}`);
}
}
CloudRunnerLogger.log('Cleanup completed (containers and non-Unity images removed, Unity images preserved)');
} catch (cleanupError) {
CloudRunnerLogger.logWarning(`Failed to cleanup images before job creation: ${cleanupError}`);
// Continue anyway - image might already be cached
}
}
let output = '';
try {
// Before creating the job, verify we have the Unity image cached on the agent node
// If not cached, try to ensure it's available to avoid disk pressure during pull
if (process.env['cloudRunnerTests'] === 'true' && image.includes('unityci/editor')) {
try {
const { CloudRunnerSystem: CloudRunnerSystemModule2 } = await import(
'../../services/core/cloud-runner-system'
);
// Check if image is cached on agent node (where pods run)
const agentImageCheck = await CloudRunnerSystemModule2.Run(
`docker exec k3d-unity-builder-agent-0 sh -c "crictl images | grep -q unityci/editor && echo 'cached' || echo 'not_cached'" || echo 'not_cached'`,
true,
true,
);
if (agentImageCheck.includes('not_cached')) {
// Check if image is on server node
const serverImageCheck = await CloudRunnerSystemModule2.Run(
`docker exec k3d-unity-builder-server-0 sh -c "crictl images | grep -q unityci/editor && echo 'cached' || echo 'not_cached'" || echo 'not_cached'`,
true,
true,
);
// Check available disk space on agent node
const diskInfo = await CloudRunnerSystemModule2.Run(
'docker exec k3d-unity-builder-agent-0 sh -c "df -h /var/lib/rancher/k3s 2>/dev/null | tail -1 || df -h / 2>/dev/null | tail -1 || echo unknown" || echo unknown',
true,
true,
);
CloudRunnerLogger.logWarning(
`Unity image not cached on agent node (where pods run). Server node: ${
serverImageCheck.includes('cached') ? 'has image' : 'no image'
}. Disk info: ${diskInfo.trim()}. Pod will attempt to pull image (3.9GB) which may fail due to disk pressure.`,
);
// If image is on server but not agent, log a warning
// NOTE: We don't attempt to pull here because:
// 1. Pulling a 3.9GB image can take several minutes and block the test
// 2. If there's not enough disk space, the pull will hang indefinitely
// 3. The pod will attempt to pull during scheduling anyway
// 4. If the pull fails, Kubernetes will provide proper error messages
if (serverImageCheck.includes('cached')) {
CloudRunnerLogger.logWarning(
'Unity image exists on server node but not agent node. Pod will attempt to pull during scheduling. If pull fails due to disk pressure, ensure cleanup runs before this test.',
);
} else {
// Image not on either node - check if we have enough space to pull
// Extract available space from disk info
const availableSpaceMatch = diskInfo.match(/(\d+(?:\.\d+)?)\s*([gkm]?i?b)/i);
if (availableSpaceMatch) {
const availableValue = Number.parseFloat(availableSpaceMatch[1]);
const availableUnit = availableSpaceMatch[2].toUpperCase();
let availableGB = availableValue;
if (availableUnit.includes('M')) {
availableGB = availableValue / 1024;
} else if (availableUnit.includes('K')) {
availableGB = availableValue / (1024 * 1024);
}
// Unity image is ~3.9GB, need at least 4.5GB to be safe
if (availableGB < 4.5) {
CloudRunnerLogger.logWarning(
`CRITICAL: Unity image not cached and only ${availableGB.toFixed(
2,
)}GB available. Image pull (3.9GB) will likely fail. Consider running cleanup or ensuring pre-pull step succeeds.`,
);
}
}
}
} else {
CloudRunnerLogger.log('Unity image is cached on agent node - pod should start without pulling');
}
} catch (checkError) {
// Ignore check errors - continue with job creation
CloudRunnerLogger.logWarning(`Failed to verify Unity image cache: ${checkError}`);
}
}
CloudRunnerLogger.log('Job does not exist');
await this.createJob(commands, image, mountdir, workingdir, environment, secrets);
CloudRunnerLogger.log('Watching pod until running');
await KubernetesTaskRunner.watchUntilPodRunning(this.kubeClient, this.podName, this.namespace);
CloudRunnerLogger.log('Pod is running');
CloudRunnerLogger.log('Pod running, streaming logs');
CloudRunnerLogger.log(
`Starting logs follow for pod: ${this.podName} container: ${this.containerName} namespace: ${this.namespace} pvc: ${this.pvcName} ${CloudRunner.buildParameters.kubeVolumeSize}/${CloudRunner.buildParameters.containerCpu}/${CloudRunner.buildParameters.containerMemory}`,
);
output += await KubernetesTaskRunner.runTask(
this.kubeConfig,
this.kubeClient,
@@ -378,12 +232,8 @@ class Kubernetes implements ProviderInterface {
this.jobName,
k8s,
this.containerName,
this.ip,
);
await new Promise((promise) => setTimeout(promise, 15000));
// await KubernetesRole.createRole(this.serviceAccountName, this.namespace, this.rbacAuthorizationV1Api);
const result = await this.kubeClientBatch.createNamespacedJob(this.namespace, jobSpec);
CloudRunnerLogger.log(`Build job created`);
await new Promise((promise) => setTimeout(promise, 5000));
@@ -407,7 +257,6 @@ class Kubernetes implements ProviderInterface {
try {
await this.kubeClientBatch.deleteNamespacedJob(this.jobName, this.namespace);
await this.kubeClient.deleteNamespacedPod(this.podName, this.namespace);
await KubernetesRole.deleteRole(this.serviceAccountName, this.namespace, this.rbacAuthorizationV1Api);
} catch (error: any) {
CloudRunnerLogger.log(`Failed to cleanup`);
if (error.response.body.reason !== `NotFound`) {
@@ -426,13 +275,14 @@ class Kubernetes implements ProviderInterface {
}
async cleanupWorkflow(
buildGuid: string,
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
if (BuildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(buildParameters)) {
if (BuildParameters.shouldUseRetainedWorkspaceMode(buildParameters)) {
return;
}
CloudRunnerLogger.log(`deleting PVC`);

View File

@@ -4,7 +4,6 @@ import { CommandHookService } from '../../services/hooks/command-hook-service';
import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
import CloudRunnerSecret from '../../options/cloud-runner-secret';
import CloudRunner from '../../cloud-runner';
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
class KubernetesJobSpecFactory {
static getJobSpec(
@@ -21,43 +20,7 @@ class KubernetesJobSpecFactory {
jobName: string,
k8s: any,
containerName: string,
ip: string = '',
) {
const endpointEnvironmentNames = new Set([
'AWS_S3_ENDPOINT',
'AWS_ENDPOINT',
'AWS_CLOUD_FORMATION_ENDPOINT',
'AWS_ECS_ENDPOINT',
'AWS_KINESIS_ENDPOINT',
'AWS_CLOUD_WATCH_LOGS_ENDPOINT',
'INPUT_AWSS3ENDPOINT',
'INPUT_AWSENDPOINT',
]);
// Determine the LocalStack hostname to use for K8s pods
// Priority: K8S_LOCALSTACK_HOST env var > localstack-main (container name on shared network)
// Note: Using K8S_LOCALSTACK_HOST instead of LOCALSTACK_HOST to avoid conflict with awslocal CLI
const localstackHost = process.env['K8S_LOCALSTACK_HOST'] || 'localstack-main';
CloudRunnerLogger.log(`K8s pods will use LocalStack host: ${localstackHost}`);
const adjustedEnvironment = environment.map((x) => {
let value = x.value;
if (
typeof value === 'string' &&
endpointEnvironmentNames.has(x.name) &&
(value.startsWith('http://localhost') || value.startsWith('http://127.0.0.1'))
) {
// Replace localhost with the LocalStack container hostname
// When k3d and LocalStack are on the same Docker network, pods can reach LocalStack by container name
value = value
.replace('http://localhost', `http://${localstackHost}`)
.replace('http://127.0.0.1', `http://${localstackHost}`);
CloudRunnerLogger.log(`Replaced localhost with ${localstackHost} for ${x.name}: ${value}`);
}
return { name: x.name, value } as CloudRunnerEnvironmentVariable;
});
const job = new k8s.V1Job();
job.apiVersion = 'batch/v1';
job.kind = 'Job';
@@ -68,16 +31,11 @@ class KubernetesJobSpecFactory {
buildGuid,
},
};
// Reduce TTL for tests to free up resources faster (default 9999s = ~2.8 hours)
// For CI/test environments, use shorter TTL (300s = 5 minutes) to prevent disk pressure
const jobTTL = process.env['cloudRunnerTests'] === 'true' ? 300 : 9999;
job.spec = {
ttlSecondsAfterFinished: jobTTL,
ttlSecondsAfterFinished: 9999,
backoffLimit: 0,
template: {
spec: {
terminationGracePeriodSeconds: 90, // Give PreStopHook (60s sleep) time to complete
volumes: [
{
name: 'build-mount',
@@ -91,7 +49,6 @@ class KubernetesJobSpecFactory {
ttlSecondsAfterFinished: 9999,
name: containerName,
image,
imagePullPolicy: process.env['cloudRunnerTests'] === 'true' ? 'IfNotPresent' : 'Always',
command: ['/bin/sh'],
args: [
'-c',
@@ -100,32 +57,13 @@ class KubernetesJobSpecFactory {
workingDir: `${workingDirectory}`,
resources: {
requests: (() => {
// Use smaller resource requests for lightweight hook containers
// Hook containers typically use utility images like aws-cli, rclone, etc.
const lightweightImages = ['amazon/aws-cli', 'rclone/rclone', 'steamcmd/steamcmd', 'ubuntu'];
const isLightweightContainer = lightweightImages.some((lightImage) => image.includes(lightImage));
if (isLightweightContainer && process.env['cloudRunnerTests'] === 'true') {
// For test environments, use minimal resources for hook containers
return {
memory: '128Mi',
cpu: '100m', // 0.1 CPU
};
}
// For main build containers, use the configured resources
const memoryMB = Number.parseInt(buildParameters.containerMemory);
const cpuMB = Number.parseInt(buildParameters.containerCpu);
return {
memory: !Number.isNaN(memoryMB) && memoryMB > 0 ? `${memoryMB / 1024}G` : '750M',
cpu: !Number.isNaN(cpuMB) && cpuMB > 0 ? `${cpuMB / 1024}` : '1',
};
})(),
requests: {
memory: `${Number.parseInt(buildParameters.containerMemory) / 1024}G` || '750M',
cpu: Number.parseInt(buildParameters.containerCpu) / 1024 || '1',
},
},
env: [
...adjustedEnvironment.map((x) => {
...environment.map((x) => {
const environmentVariable = new V1EnvVar();
environmentVariable.name = x.name;
environmentVariable.value = x.value;
@@ -143,7 +81,6 @@ class KubernetesJobSpecFactory {
return environmentVariable;
}),
{ name: 'LOG_SERVICE_IP', value: ip },
],
volumeMounts: [
{
@@ -155,9 +92,11 @@ class KubernetesJobSpecFactory {
preStop: {
exec: {
command: [
'/bin/sh',
'bin/bash',
'-c',
'sleep 60; cd /data/builder/action/steps && chmod +x /steps/return_license.sh 2>/dev/null || true; /steps/return_license.sh 2>/dev/null || true',
`cd /data/builder/action/steps;
chmod +x /return_license.sh;
/return_license.sh;`,
],
},
},
@@ -165,42 +104,11 @@ class KubernetesJobSpecFactory {
},
],
restartPolicy: 'Never',
// Add tolerations for CI/test environments to allow scheduling even with disk pressure
// This is acceptable for CI where we aggressively clean up disk space
tolerations: [
{
key: 'node.kubernetes.io/disk-pressure',
operator: 'Exists',
effect: 'NoSchedule',
},
],
},
},
};
if (process.env['CLOUD_RUNNER_MINIKUBE']) {
job.spec.template.spec.volumes[0] = {
name: 'build-mount',
hostPath: {
path: `/data`,
type: `Directory`,
},
};
}
// Set ephemeral-storage request to a reasonable value to prevent evictions
// For tests, don't set a request (or use minimal 128Mi) since k3d nodes have very limited disk space
// Kubernetes will use whatever is available without a request, which is better for constrained environments
// For production, use 2Gi to allow for larger builds
// The node needs some free space headroom, so requesting too much causes evictions
// With node at 96% usage and only ~2.7GB free, we can't request much without triggering evictions
if (process.env['cloudRunnerTests'] !== 'true') {
// Only set ephemeral-storage request for production builds
job.spec.template.spec.containers[0].resources.requests[`ephemeral-storage`] = '2Gi';
}
// For tests, don't set ephemeral-storage request - let Kubernetes use available space
job.spec.template.spec.containers[0].resources.requests[`ephemeral-storage`] = '10Gi';
return job;
}

View File

@@ -7,178 +7,7 @@ class KubernetesPods {
const phase = pods[0]?.status?.phase || 'undefined status';
CloudRunnerLogger.log(`Getting pod status: ${phase}`);
if (phase === `Failed`) {
const pod = pods[0];
const containerStatuses = pod.status?.containerStatuses || [];
const conditions = pod.status?.conditions || [];
const events = (await kubeClient.listNamespacedEvent(namespace)).body.items
.filter((x) => x.involvedObject?.name === podName)
.map((x) => ({
message: x.message || '',
reason: x.reason || '',
type: x.type || '',
}));
const errorDetails: string[] = [];
errorDetails.push(`Pod: ${podName}`, `Phase: ${phase}`);
if (conditions.length > 0) {
errorDetails.push(
`Conditions: ${JSON.stringify(
conditions.map((c) => ({ type: c.type, status: c.status, reason: c.reason, message: c.message })),
undefined,
2,
)}`,
);
}
let containerExitCode: number | undefined;
let containerSucceeded = false;
if (containerStatuses.length > 0) {
for (const [index, cs] of containerStatuses.entries()) {
if (cs.state?.waiting) {
errorDetails.push(
`Container ${index} (${cs.name}) waiting: ${cs.state.waiting.reason} - ${cs.state.waiting.message || ''}`,
);
}
if (cs.state?.terminated) {
const exitCode = cs.state.terminated.exitCode;
containerExitCode = exitCode;
if (exitCode === 0) {
containerSucceeded = true;
}
errorDetails.push(
`Container ${index} (${cs.name}) terminated: ${cs.state.terminated.reason} - ${
cs.state.terminated.message || ''
} (exit code: ${exitCode})`,
);
}
}
}
if (events.length > 0) {
errorDetails.push(`Recent events: ${JSON.stringify(events.slice(-5), undefined, 2)}`);
}
// Check if only PreStopHook failed but container succeeded
const hasPreStopHookFailure = events.some((event) => event.reason === 'FailedPreStopHook');
const wasKilled = events.some((event) => event.reason === 'Killing');
const hasExceededGracePeriod = events.some((event) => event.reason === 'ExceededGracePeriod');
// If container succeeded (exit code 0), PreStopHook failure is non-critical
// Also check if pod was killed but container might have succeeded
if (containerSucceeded && containerExitCode === 0) {
// Container succeeded - PreStopHook failure is non-critical
if (hasPreStopHookFailure) {
CloudRunnerLogger.logWarning(
`Pod ${podName} marked as Failed due to PreStopHook failure, but container exited successfully (exit code 0). This is non-fatal.`,
);
} else {
CloudRunnerLogger.log(
`Pod ${podName} container succeeded (exit code 0), but pod phase is Failed. Checking details...`,
);
}
CloudRunnerLogger.log(`Pod details: ${errorDetails.join('\n')}`);
// Don't throw error - container succeeded, PreStopHook failure is non-critical
return false; // Pod is not running, but we don't treat it as a failure
}
// If pod was killed and we have PreStopHook failure, wait for container status
// The container might have succeeded but status hasn't been updated yet
if (wasKilled && hasPreStopHookFailure && (containerExitCode === undefined || !containerSucceeded)) {
CloudRunnerLogger.log(
`Pod ${podName} was killed with PreStopHook failure. Waiting for container status to determine if container succeeded...`,
);
// Wait a bit for container status to become available (up to 30 seconds)
for (let index = 0; index < 6; index++) {
await new Promise((resolve) => setTimeout(resolve, 5000));
try {
const updatedPod = (await kubeClient.listNamespacedPod(namespace)).body.items.find(
(x) => podName === x.metadata?.name,
);
if (updatedPod?.status?.containerStatuses && updatedPod.status.containerStatuses.length > 0) {
const updatedContainerStatus = updatedPod.status.containerStatuses[0];
if (updatedContainerStatus.state?.terminated) {
const updatedExitCode = updatedContainerStatus.state.terminated.exitCode;
if (updatedExitCode === 0) {
CloudRunnerLogger.logWarning(
`Pod ${podName} container succeeded (exit code 0) after waiting. PreStopHook failure is non-fatal.`,
);
return false; // Pod is not running, but container succeeded
} else {
CloudRunnerLogger.log(
`Pod ${podName} container failed with exit code ${updatedExitCode} after waiting.`,
);
errorDetails.push(`Container terminated after wait: exit code ${updatedExitCode}`);
containerExitCode = updatedExitCode;
containerSucceeded = false;
break;
}
}
}
} catch (waitError) {
CloudRunnerLogger.log(`Error while waiting for container status: ${waitError}`);
}
}
// If we still don't have container status after waiting, but only PreStopHook failed,
// be lenient - the container might have succeeded but status wasn't updated
if (containerExitCode === undefined && hasPreStopHookFailure && !hasExceededGracePeriod) {
CloudRunnerLogger.logWarning(
`Pod ${podName} container status not available after waiting, but only PreStopHook failed (no ExceededGracePeriod). Assuming container may have succeeded.`,
);
return false; // Be lenient - PreStopHook failure alone is not fatal
}
CloudRunnerLogger.log(
`Container status check completed. Exit code: ${containerExitCode}, PreStopHook failure: ${hasPreStopHookFailure}`,
);
}
// If we only have PreStopHook failure and no actual container failure, be lenient
if (hasPreStopHookFailure && !hasExceededGracePeriod && containerExitCode === undefined) {
CloudRunnerLogger.logWarning(
`Pod ${podName} has PreStopHook failure but no container failure detected. Treating as non-fatal.`,
);
return false; // PreStopHook failure alone is not fatal if container status is unclear
}
// Check if pod was evicted due to disk pressure - this is an infrastructure issue
const wasEvicted = errorDetails.some(
(detail) => detail.toLowerCase().includes('evicted') || detail.toLowerCase().includes('diskpressure'),
);
if (wasEvicted) {
const evictionMessage = `Pod ${podName} was evicted due to disk pressure. This is a test infrastructure issue - the cluster doesn't have enough disk space.`;
CloudRunnerLogger.logWarning(evictionMessage);
CloudRunnerLogger.log(`Pod details: ${errorDetails.join('\n')}`);
throw new Error(
`${evictionMessage}\nThis indicates the test environment needs more disk space or better cleanup.\n${errorDetails.join(
'\n',
)}`,
);
}
// Exit code 137 (128 + 9) means SIGKILL - container was killed by system (often OOM)
// If this happened with PreStopHook failure, it might be a resource issue, not a real failure
// Be lenient if we only have PreStopHook/ExceededGracePeriod issues
if (containerExitCode === 137 && (hasPreStopHookFailure || hasExceededGracePeriod)) {
CloudRunnerLogger.logWarning(
`Pod ${podName} was killed (exit code 137 - likely OOM or resource limit) with PreStopHook/grace period issues. This may be a resource constraint issue rather than a build failure.`,
);
// Still log the details but don't fail the test - the build might have succeeded before being killed
CloudRunnerLogger.log(`Pod details: ${errorDetails.join('\n')}`);
return false; // Don't treat system kills as test failures if only PreStopHook issues
}
const errorMessage = `K8s pod failed\n${errorDetails.join('\n')}`;
CloudRunnerLogger.log(errorMessage);
throw new Error(errorMessage);
throw new Error(`K8s pod failed`);
}
return running;

View File

@@ -1,53 +0,0 @@
import { RbacAuthorizationV1Api } from '@kubernetes/client-node';
class KubernetesRole {
static async createRole(serviceAccountName: string, namespace: string, rbac: RbacAuthorizationV1Api) {
// create admin kubernetes role and role binding
const roleBinding = {
apiVersion: 'rbac.authorization.k8s.io/v1',
kind: 'RoleBinding',
metadata: {
name: `${serviceAccountName}-admin`,
namespace,
},
subjects: [
{
kind: 'ServiceAccount',
name: serviceAccountName,
namespace,
},
],
roleRef: {
apiGroup: 'rbac.authorization.k8s.io',
kind: 'Role',
name: `${serviceAccountName}-admin`,
},
};
const role = {
apiVersion: 'rbac.authorization.k8s.io/v1',
kind: 'Role',
metadata: {
name: `${serviceAccountName}-admin`,
namespace,
},
rules: [
{
apiGroups: ['*'],
resources: ['*'],
verbs: ['*'],
},
],
};
const roleBindingResponse = await rbac.createNamespacedRoleBinding(namespace, roleBinding);
const roleResponse = await rbac.createNamespacedRole(namespace, role);
return { roleBindingResponse, roleResponse };
}
public static async deleteRole(serviceAccountName: string, namespace: string, rbac: RbacAuthorizationV1Api) {
await rbac.deleteNamespacedRoleBinding(`${serviceAccountName}-admin`, namespace);
await rbac.deleteNamespacedRole(`${serviceAccountName}-admin`, namespace);
}
}
export { KubernetesRole };

View File

@@ -9,7 +9,7 @@ class KubernetesServiceAccount {
serviceAccount.metadata = {
name: serviceAccountName,
};
serviceAccount.automountServiceAccountToken = true;
serviceAccount.automountServiceAccountToken = false;
return kubeClient.createNamespacedServiceAccount(namespace, serviceAccount);
}

View File

@@ -47,188 +47,28 @@ class KubernetesStorage {
}
public static async watchUntilPVCNotPending(kubeClient: k8s.CoreV1Api, name: string, namespace: string) {
let checkCount = 0;
try {
CloudRunnerLogger.log(`watch Until PVC Not Pending ${name} ${namespace}`);
// Check if storage class uses WaitForFirstConsumer binding mode
// If so, skip waiting - PVC will bind when pod is created
let shouldSkipWait = false;
try {
const pvcBody = (await kubeClient.readNamespacedPersistentVolumeClaim(name, namespace)).body;
const storageClassName = pvcBody.spec?.storageClassName;
if (storageClassName) {
const kubeConfig = new k8s.KubeConfig();
kubeConfig.loadFromDefault();
const storageV1Api = kubeConfig.makeApiClient(k8s.StorageV1Api);
try {
const sc = await storageV1Api.readStorageClass(storageClassName);
const volumeBindingMode = sc.body.volumeBindingMode;
if (volumeBindingMode === 'WaitForFirstConsumer') {
CloudRunnerLogger.log(
`StorageClass "${storageClassName}" uses WaitForFirstConsumer binding mode. PVC will bind when pod is created. Skipping wait.`,
);
shouldSkipWait = true;
}
} catch (scError) {
// If we can't check the storage class, proceed with normal wait
CloudRunnerLogger.log(
`Could not check storage class binding mode: ${scError}. Proceeding with normal wait.`,
);
}
}
} catch (pvcReadError) {
// If we can't read PVC, proceed with normal wait
CloudRunnerLogger.log(
`Could not read PVC to check storage class: ${pvcReadError}. Proceeding with normal wait.`,
);
}
if (shouldSkipWait) {
CloudRunnerLogger.log(`Skipping PVC wait - will bind when pod is created`);
return;
}
const initialPhase = await this.getPVCPhase(kubeClient, name, namespace);
CloudRunnerLogger.log(`Initial PVC phase: ${initialPhase}`);
// Wait until PVC is NOT Pending (i.e., Bound or Available)
CloudRunnerLogger.log(`${await this.getPVCPhase(kubeClient, name, namespace)}`);
await waitUntil(
async () => {
checkCount++;
const phase = await this.getPVCPhase(kubeClient, name, namespace);
// Log progress every 4 checks (every ~60 seconds)
if (checkCount % 4 === 0) {
CloudRunnerLogger.log(`PVC ${name} still ${phase} (check ${checkCount})`);
// Fetch and log PVC events for diagnostics
try {
const events = await kubeClient.listNamespacedEvent(namespace);
const pvcEvents = events.body.items
.filter((x) => x.involvedObject?.kind === 'PersistentVolumeClaim' && x.involvedObject?.name === name)
.map((x) => ({
message: x.message || '',
reason: x.reason || '',
type: x.type || '',
count: x.count || 0,
}))
.slice(-5); // Get last 5 events
if (pvcEvents.length > 0) {
CloudRunnerLogger.log(`PVC Events: ${JSON.stringify(pvcEvents, undefined, 2)}`);
// Check if event indicates WaitForFirstConsumer
const waitForConsumerEvent = pvcEvents.find(
(event) =>
event.reason === 'WaitForFirstConsumer' || event.message?.includes('waiting for first consumer'),
);
if (waitForConsumerEvent) {
CloudRunnerLogger.log(
`PVC is waiting for first consumer. This is normal for WaitForFirstConsumer storage classes. Proceeding without waiting.`,
);
return true; // Exit wait loop - PVC will bind when pod is created
}
}
} catch {
// Ignore event fetch errors
}
}
return phase !== 'Pending';
return (await this.getPVCPhase(kubeClient, name, namespace)) === 'Pending';
},
{
timeout: 750000,
intervalBetweenAttempts: 15000,
},
);
const finalPhase = await this.getPVCPhase(kubeClient, name, namespace);
CloudRunnerLogger.log(`PVC phase after wait: ${finalPhase}`);
if (finalPhase === 'Pending') {
throw new Error(`PVC ${name} is still Pending after timeout`);
}
} catch (error: any) {
core.error('Failed to watch PVC');
core.error(error.toString());
try {
const pvcBody = (await kubeClient.readNamespacedPersistentVolumeClaim(name, namespace)).body;
// Fetch PVC events for detailed diagnostics
let pvcEvents: any[] = [];
try {
const events = await kubeClient.listNamespacedEvent(namespace);
pvcEvents = events.body.items
.filter((x) => x.involvedObject?.kind === 'PersistentVolumeClaim' && x.involvedObject?.name === name)
.map((x) => ({
message: x.message || '',
reason: x.reason || '',
type: x.type || '',
count: x.count || 0,
}));
} catch {
// Ignore event fetch errors
}
// Check if storage class exists
let storageClassInfo = '';
try {
const storageClassName = pvcBody.spec?.storageClassName;
if (storageClassName) {
// Create StorageV1Api from default config
const kubeConfig = new k8s.KubeConfig();
kubeConfig.loadFromDefault();
const storageV1Api = kubeConfig.makeApiClient(k8s.StorageV1Api);
try {
const sc = await storageV1Api.readStorageClass(storageClassName);
storageClassInfo = `StorageClass "${storageClassName}" exists. Provisioner: ${
sc.body.provisioner || 'unknown'
}`;
} catch (scError: any) {
storageClassInfo =
scError.statusCode === 404
? `StorageClass "${storageClassName}" does NOT exist! This is likely why the PVC is stuck in Pending.`
: `Failed to check StorageClass "${storageClassName}": ${scError.message || scError}`;
}
}
} catch (scCheckError) {
// Ignore storage class check errors - not critical for diagnostics
storageClassInfo = `Could not check storage class: ${scCheckError}`;
}
core.error(
`PVC Body: ${JSON.stringify(
{
phase: pvcBody.status?.phase,
conditions: pvcBody.status?.conditions,
accessModes: pvcBody.spec?.accessModes,
storageClassName: pvcBody.spec?.storageClassName,
storageRequest: pvcBody.spec?.resources?.requests?.storage,
},
undefined,
4,
)}`,
);
if (storageClassInfo) {
core.error(storageClassInfo);
}
if (pvcEvents.length > 0) {
core.error(`PVC Events: ${JSON.stringify(pvcEvents, undefined, 2)}`);
} else {
core.error('No PVC events found - this may indicate the storage provisioner is not responding');
}
} catch {
// Ignore PVC read errors
}
core.error(
`PVC Body: ${JSON.stringify(
(await kubeClient.readNamespacedPersistentVolumeClaim(name, namespace)).body,
undefined,
4,
)}`,
);
throw error;
}
}

View File

@@ -7,6 +7,7 @@ import KubernetesPods from './kubernetes-pods';
import { FollowLogStreamService } from '../../services/core/follow-log-stream-service';
class KubernetesTaskRunner {
static lastReceivedTimestamp: number = 0;
static readonly maxRetry: number = 3;
static lastReceivedMessage: string = ``;
@@ -21,195 +22,79 @@ class KubernetesTaskRunner {
let output = '';
let shouldReadLogs = true;
let shouldCleanup = true;
let sinceTime = ``;
let retriesAfterFinish = 0;
let kubectlLogsFailedCount = 0;
const maxKubectlLogsFailures = 3;
// eslint-disable-next-line no-constant-condition
while (true) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const lastReceivedMessage =
KubernetesTaskRunner.lastReceivedTimestamp > 0
? `\nLast Log Message "${this.lastReceivedMessage}" ${this.lastReceivedTimestamp}`
: ``;
CloudRunnerLogger.log(
`Streaming logs from pod: ${podName} container: ${containerName} namespace: ${namespace} ${CloudRunner.buildParameters.kubeVolumeSize}/${CloudRunner.buildParameters.containerCpu}/${CloudRunner.buildParameters.containerMemory}`,
`Streaming logs from pod: ${podName} container: ${containerName} namespace: ${namespace} ${CloudRunner.buildParameters.kubeVolumeSize}/${CloudRunner.buildParameters.containerCpu}/${CloudRunner.buildParameters.containerMemory}\n${lastReceivedMessage}`,
);
const isRunning = await KubernetesPods.IsPodRunning(podName, namespace, kubeClient);
if (KubernetesTaskRunner.lastReceivedTimestamp > 0) {
const currentDate = new Date(KubernetesTaskRunner.lastReceivedTimestamp);
const dateTimeIsoString = currentDate.toISOString();
sinceTime = ` --since-time="${dateTimeIsoString}"`;
}
let extraFlags = ``;
extraFlags += (await KubernetesPods.IsPodRunning(podName, namespace, kubeClient))
? ` -f -c ${containerName}`
: ` --previous`;
let lastMessageSeenIncludedInChunk = false;
let lastMessageSeen = false;
const callback = (outputChunk: string) => {
// Filter out kubectl error messages about being unable to retrieve container logs
// These errors pollute the output and don't contain useful information
const lowerChunk = outputChunk.toLowerCase();
if (lowerChunk.includes('unable to retrieve container logs')) {
CloudRunnerLogger.log(`Filtered kubectl error: ${outputChunk.trim()}`);
let logs;
return;
}
output += outputChunk;
// split output chunk and handle per line
for (const chunk of outputChunk.split(`\n`)) {
// Skip empty chunks and kubectl error messages (case-insensitive)
const lowerCaseChunk = chunk.toLowerCase();
if (chunk.trim() && !lowerCaseChunk.includes('unable to retrieve container logs')) {
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
chunk,
shouldReadLogs,
shouldCleanup,
output,
));
}
}
};
try {
// Always specify container name explicitly to avoid containerd:// errors
// Use -f for running pods, --previous for terminated pods
await CloudRunnerSystem.Run(
`kubectl logs ${podName} -c ${containerName} -n ${namespace}${isRunning ? ' -f' : ' --previous'}`,
logs = await CloudRunnerSystem.Run(
`kubectl logs ${podName}${extraFlags} --timestamps${sinceTime}`,
false,
true,
callback,
);
// Reset failure count on success
kubectlLogsFailedCount = 0;
} catch (error: any) {
kubectlLogsFailedCount++;
await new Promise((resolve) => setTimeout(resolve, 3000));
const continueStreaming = await KubernetesPods.IsPodRunning(podName, namespace, kubeClient);
CloudRunnerLogger.log(`K8s logging error ${error} ${continueStreaming}`);
// Filter out kubectl error messages from the error output
const errorMessage = error?.message || error?.toString() || '';
const isKubectlLogsError =
errorMessage.includes('unable to retrieve container logs for containerd://') ||
errorMessage.toLowerCase().includes('unable to retrieve container logs');
if (isKubectlLogsError) {
CloudRunnerLogger.log(
`Kubectl unable to retrieve logs, attempt ${kubectlLogsFailedCount}/${maxKubectlLogsFailures}`,
);
// If kubectl logs has failed multiple times, try reading the log file directly from the pod
// This works even if the pod is terminated, as long as it hasn't been deleted
if (kubectlLogsFailedCount >= maxKubectlLogsFailures && !isRunning && !continueStreaming) {
CloudRunnerLogger.log(`Attempting to read log file directly from pod as fallback...`);
try {
// Try to read the log file from the pod
// Use kubectl exec for running pods, or try to access via PVC if pod is terminated
let logFileContent = '';
if (isRunning) {
// Pod is still running, try exec
logFileContent = await CloudRunnerSystem.Run(
`kubectl exec ${podName} -c ${containerName} -n ${namespace} -- cat /home/job-log.txt 2>/dev/null || echo ""`,
true,
true,
);
} else {
// Pod is terminated, try to create a temporary pod to read from the PVC
// First, check if we can still access the pod's filesystem
CloudRunnerLogger.log(`Pod is terminated, attempting to read log file via temporary pod...`);
// For terminated pods, we might not be able to exec, so we'll skip this fallback
// and rely on the log file being written to the PVC (if mounted)
CloudRunnerLogger.logWarning(`Cannot read log file from terminated pod via exec`);
}
if (logFileContent && logFileContent.trim()) {
CloudRunnerLogger.log(`Successfully read log file from pod (${logFileContent.length} chars)`);
// Process the log file content line by line
for (const line of logFileContent.split(`\n`)) {
const lowerLine = line.toLowerCase();
if (line.trim() && !lowerLine.includes('unable to retrieve container logs')) {
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
line,
shouldReadLogs,
shouldCleanup,
output,
));
}
}
// Check if we got the end of transmission marker
if (FollowLogStreamService.DidReceiveEndOfTransmission) {
CloudRunnerLogger.log('end of log stream (from log file)');
break;
}
} else {
CloudRunnerLogger.logWarning(`Log file read returned empty content, continuing with available logs`);
// If we can't read the log file, break out of the loop to return whatever logs we have
// This prevents infinite retries when kubectl logs consistently fails
break;
}
} catch (execError: any) {
CloudRunnerLogger.logWarning(`Failed to read log file from pod: ${execError}`);
// If we've exhausted all options, break to return whatever logs we have
break;
}
}
}
// If pod is not running and we tried --previous but it failed, try without --previous
if (!isRunning && !continueStreaming && error?.message?.includes('previous terminated container')) {
CloudRunnerLogger.log(`Previous container not found, trying current container logs...`);
try {
await CloudRunnerSystem.Run(
`kubectl logs ${podName} -c ${containerName} -n ${namespace}`,
false,
true,
callback,
);
// If we successfully got logs, check for end of transmission
if (FollowLogStreamService.DidReceiveEndOfTransmission) {
CloudRunnerLogger.log('end of log stream');
break;
}
// If we got logs but no end marker, continue trying (might be more logs)
if (retriesAfterFinish < KubernetesTaskRunner.maxRetry) {
retriesAfterFinish++;
continue;
}
// If we've exhausted retries, break
break;
} catch (fallbackError: any) {
CloudRunnerLogger.log(`Fallback log fetch also failed: ${fallbackError}`);
// If both fail, continue retrying if we haven't exhausted retries
if (retriesAfterFinish < KubernetesTaskRunner.maxRetry) {
retriesAfterFinish++;
continue;
}
// Only break if we've exhausted all retries
CloudRunnerLogger.logWarning(
`Could not fetch any container logs after ${KubernetesTaskRunner.maxRetry} retries`,
);
break;
}
}
if (continueStreaming) {
continue;
}
if (retriesAfterFinish < KubernetesTaskRunner.maxRetry) {
retriesAfterFinish++;
continue;
}
// If we've exhausted retries and it's not a previous container issue, throw
if (!error?.message?.includes('previous terminated container')) {
throw error;
throw error;
}
const splitLogs = logs.split(`\n`);
for (const chunk of splitLogs) {
if (
chunk.replace(/\s/g, ``) === KubernetesTaskRunner.lastReceivedMessage.replace(/\s/g, ``) &&
KubernetesTaskRunner.lastReceivedMessage.replace(/\s/g, ``) !== ``
) {
CloudRunnerLogger.log(`Previous log message found ${chunk}`);
lastMessageSeenIncludedInChunk = true;
}
// For previous container errors, we've already tried fallback, so just break
CloudRunnerLogger.logWarning(
`Could not fetch previous container logs after retries, but continuing with available logs`,
);
break;
}
for (const chunk of splitLogs) {
const newDate = Date.parse(`${chunk.toString().split(`Z `)[0]}Z`);
if (chunk.replace(/\s/g, ``) === KubernetesTaskRunner.lastReceivedMessage.replace(/\s/g, ``)) {
lastMessageSeen = true;
}
if (lastMessageSeenIncludedInChunk && !lastMessageSeen) {
continue;
}
const message = CloudRunner.buildParameters.cloudRunnerDebug ? chunk : chunk.split(`Z `)[1];
KubernetesTaskRunner.lastReceivedMessage = chunk;
KubernetesTaskRunner.lastReceivedTimestamp = newDate;
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
message,
shouldReadLogs,
shouldCleanup,
output,
));
}
if (FollowLogStreamService.DidReceiveEndOfTransmission) {
CloudRunnerLogger.log('end of log stream');
@@ -217,546 +102,51 @@ class KubernetesTaskRunner {
}
}
// After kubectl logs loop ends, read log file as fallback to capture any messages
// written after kubectl stopped reading (e.g., "Collected Logs" from post-build)
// This ensures all log messages are included in BuildResults for test assertions
// If output is empty, we need to be more aggressive about getting logs
const needsFallback = output.trim().length === 0;
const missingCollectedLogs = !output.includes('Collected Logs');
if (needsFallback) {
CloudRunnerLogger.log('Output is empty, attempting aggressive log collection fallback...');
// Give the pod a moment to finish writing logs before we try to read them
await new Promise((resolve) => setTimeout(resolve, 5000));
}
// Always try fallback if output is empty, if pod is terminated, or if "Collected Logs" is missing
// The "Collected Logs" check ensures we try to get post-build messages even if we have some output
try {
const isPodStillRunning = await KubernetesPods.IsPodRunning(podName, namespace, kubeClient);
const shouldTryFallback = !isPodStillRunning || needsFallback || missingCollectedLogs;
if (shouldTryFallback) {
const reason = needsFallback
? 'output is empty'
: missingCollectedLogs
? 'Collected Logs missing from output'
: 'pod is terminated';
CloudRunnerLogger.log(
`Pod is ${isPodStillRunning ? 'running' : 'terminated'} and ${reason}, reading log file as fallback...`,
);
try {
// Try to read the log file from the pod
// For killed pods (OOM), kubectl exec might not work, so we try multiple approaches
// First try --previous flag for terminated containers, then try without it
let logFileContent = '';
// Try multiple approaches to get the log file
// Order matters: try terminated container first, then current, then PVC, then kubectl logs as last resort
// For K8s, the PVC is mounted at /data, so try reading from there too
const attempts = [
// For terminated pods, try --previous first
`kubectl exec ${podName} -c ${containerName} -n ${namespace} --previous -- cat /home/job-log.txt 2>/dev/null || echo ""`,
// Try current container
`kubectl exec ${podName} -c ${containerName} -n ${namespace} -- cat /home/job-log.txt 2>/dev/null || echo ""`,
// Try reading from PVC (/data) in case log was copied there
`kubectl exec ${podName} -c ${containerName} -n ${namespace} --previous -- cat /data/job-log.txt 2>/dev/null || echo ""`,
`kubectl exec ${podName} -c ${containerName} -n ${namespace} -- cat /data/job-log.txt 2>/dev/null || echo ""`,
// Try kubectl logs as fallback (might capture stdout even if exec fails)
`kubectl logs ${podName} -c ${containerName} -n ${namespace} --previous 2>/dev/null || echo ""`,
`kubectl logs ${podName} -c ${containerName} -n ${namespace} 2>/dev/null || echo ""`,
];
for (const attempt of attempts) {
// If we already have content with "Collected Logs", no need to try more
if (logFileContent && logFileContent.trim() && logFileContent.includes('Collected Logs')) {
CloudRunnerLogger.log('Found "Collected Logs" in fallback content, stopping attempts.');
break;
}
try {
CloudRunnerLogger.log(`Trying fallback method: ${attempt.slice(0, 80)}...`);
const result = await CloudRunnerSystem.Run(attempt, true, true);
if (result && result.trim()) {
// Prefer content that has "Collected Logs" over content that doesn't
if (!logFileContent || !logFileContent.includes('Collected Logs')) {
logFileContent = result;
CloudRunnerLogger.log(
`Successfully read logs using fallback method (${logFileContent.length} chars): ${attempt.slice(
0,
50,
)}...`,
);
// If this content has "Collected Logs", we're done
if (logFileContent.includes('Collected Logs')) {
CloudRunnerLogger.log('Fallback method successfully captured "Collected Logs".');
break;
}
} else {
CloudRunnerLogger.log(`Skipping this result - already have content with "Collected Logs".`);
}
} else {
CloudRunnerLogger.log(`Fallback method returned empty result: ${attempt.slice(0, 50)}...`);
}
} catch (attemptError: any) {
CloudRunnerLogger.log(
`Fallback method failed: ${attempt.slice(0, 50)}... Error: ${attemptError?.message || attemptError}`,
);
// Continue to next attempt
}
}
if (!logFileContent || !logFileContent.trim()) {
CloudRunnerLogger.logWarning(
'Could not read log file from pod after all fallback attempts (may be OOM-killed or pod not accessible).',
);
}
if (logFileContent && logFileContent.trim()) {
CloudRunnerLogger.log(
`Read log file from pod as fallback (${logFileContent.length} chars) to capture missing messages`,
);
// Get the lines we already have in output to avoid duplicates
const existingLines = new Set(output.split('\n').map((line) => line.trim()));
// Process the log file content line by line and add missing lines
for (const line of logFileContent.split(`\n`)) {
const trimmedLine = line.trim();
const lowerLine = trimmedLine.toLowerCase();
// Skip empty lines, kubectl errors, and lines we already have
if (
trimmedLine &&
!lowerLine.includes('unable to retrieve container logs') &&
!existingLines.has(trimmedLine)
) {
// Process through FollowLogStreamService - it will append to output
// Don't add to output manually since handleIteration does it
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
trimmedLine,
shouldReadLogs,
shouldCleanup,
output,
));
}
}
}
} catch (logFileError: any) {
CloudRunnerLogger.logWarning(
`Could not read log file from pod as fallback: ${logFileError?.message || logFileError}`,
);
// Continue with existing output - this is a best-effort fallback
}
}
// If output is still empty or missing "Collected Logs" after fallback attempts, add a warning message
// This ensures BuildResults is not completely empty, which would cause test failures
if ((needsFallback && output.trim().length === 0) || (!output.includes('Collected Logs') && shouldTryFallback)) {
CloudRunnerLogger.logWarning(
'Could not retrieve "Collected Logs" from pod after all attempts. Pod may have been killed before logs were written.',
);
// Add a minimal message so BuildResults is not completely empty
// This helps with debugging and prevents test failures due to empty results
if (output.trim().length === 0) {
output = 'Pod logs unavailable - pod may have been terminated before logs could be collected.\n';
} else if (!output.includes('Collected Logs')) {
// We have some output but missing "Collected Logs" - append the fallback message
output +=
'\nPod logs incomplete - "Collected Logs" marker not found. Pod may have been terminated before post-build completed.\n';
}
}
} catch (fallbackError: any) {
CloudRunnerLogger.logWarning(
`Error checking pod status for log file fallback: ${fallbackError?.message || fallbackError}`,
);
// If output is empty and we hit an error, still add a message so BuildResults isn't empty
if (needsFallback && output.trim().length === 0) {
output = `Error retrieving logs: ${fallbackError?.message || fallbackError}\n`;
}
// Continue with existing output - this is a best-effort fallback
}
// Filter out kubectl error messages from the final output
// These errors can be added via stderr even when kubectl fails
// We filter them out so they don't pollute the BuildResults
const lines = output.split('\n');
const filteredLines = lines.filter((line) => !line.toLowerCase().includes('unable to retrieve container logs'));
const filteredOutput = filteredLines.join('\n');
// Log if we filtered out significant content
const originalLineCount = lines.length;
const filteredLineCount = filteredLines.length;
if (originalLineCount > filteredLineCount) {
CloudRunnerLogger.log(
`Filtered out ${originalLineCount - filteredLineCount} kubectl error message(s) from output`,
);
}
return filteredOutput;
return output;
}
static async watchUntilPodRunning(kubeClient: CoreV1Api, podName: string, namespace: string) {
let waitComplete: boolean = false;
let success: boolean = false;
let message = ``;
let lastPhase = '';
let consecutivePendingCount = 0;
CloudRunnerLogger.log(`Watching ${podName} ${namespace}`);
await waitUntil(
async () => {
const status = await kubeClient.readNamespacedPodStatus(podName, namespace);
const phase = status?.body.status?.phase;
success = phase === 'Running';
message = `Phase:${status.body.status?.phase} \n Reason:${
status.body.status?.conditions?.[0].reason || ''
} \n Message:${status.body.status?.conditions?.[0].message || ''}`;
try {
await waitUntil(
async () => {
const status = await kubeClient.readNamespacedPodStatus(podName, namespace);
const phase = status?.body.status?.phase || 'Unknown';
const conditions = status?.body.status?.conditions || [];
const containerStatuses = status?.body.status?.containerStatuses || [];
// CloudRunnerLogger.log(
// JSON.stringify(
// (await kubeClient.listNamespacedEvent(namespace)).body.items
// .map((x) => {
// return {
// message: x.message || ``,
// name: x.metadata.name || ``,
// reason: x.reason || ``,
// };
// })
// .filter((x) => x.name.includes(podName)),
// undefined,
// 4,
// ),
// );
if (success || phase !== 'Pending') return true;
// Log phase changes
if (phase !== lastPhase) {
CloudRunnerLogger.log(`Pod ${podName} phase changed: ${lastPhase} -> ${phase}`);
lastPhase = phase;
consecutivePendingCount = 0;
}
// Check for failure conditions that mean the pod will never start (permanent failures)
// Note: We don't treat "Failed" phase as a permanent failure because the pod might have
// completed its work before being killed (OOM), and we should still try to get logs
const permanentFailureReasons = [
'Unschedulable',
'ImagePullBackOff',
'ErrImagePull',
'CreateContainerError',
'CreateContainerConfigError',
];
const hasPermanentFailureCondition = conditions.some((condition: any) =>
permanentFailureReasons.some((reason) => condition.reason?.includes(reason)),
);
const hasPermanentFailureContainerStatus = containerStatuses.some((containerStatus: any) =>
permanentFailureReasons.some((reason) => containerStatus.state?.waiting?.reason?.includes(reason)),
);
// Only treat permanent failures as errors - pods that completed (Failed/Succeeded) should continue
if (hasPermanentFailureCondition || hasPermanentFailureContainerStatus) {
// Get detailed failure information
const failureCondition = conditions.find((condition: any) =>
permanentFailureReasons.some((reason) => condition.reason?.includes(reason)),
);
const failureContainer = containerStatuses.find((containerStatus: any) =>
permanentFailureReasons.some((reason) => containerStatus.state?.waiting?.reason?.includes(reason)),
);
message = `Pod ${podName} failed to start (permanent failure):\nPhase: ${phase}\n`;
if (failureCondition) {
message += `Condition Reason: ${failureCondition.reason}\nCondition Message: ${failureCondition.message}\n`;
}
if (failureContainer) {
message += `Container Reason: ${failureContainer.state?.waiting?.reason}\nContainer Message: ${failureContainer.state?.waiting?.message}\n`;
}
// Log pod events for additional context
try {
const events = await kubeClient.listNamespacedEvent(namespace);
const podEvents = events.body.items
.filter((x) => x.involvedObject?.name === podName)
.map((x) => ({
message: x.message || ``,
reason: x.reason || ``,
type: x.type || ``,
}));
if (podEvents.length > 0) {
message += `\nRecent Events:\n${JSON.stringify(podEvents.slice(-5), undefined, 2)}`;
}
} catch {
// Ignore event fetch errors
}
CloudRunnerLogger.logWarning(message);
// For permanent failures, mark as incomplete and store the error message
// We'll throw an error after the wait loop exits
waitComplete = false;
return true; // Return true to exit wait loop
}
// Pod is complete if it's not Pending or Unknown - it might be Running, Succeeded, or Failed
// For Failed/Succeeded pods, we still want to try to get logs, so we mark as complete
waitComplete = phase !== 'Pending' && phase !== 'Unknown';
// If pod completed (Succeeded/Failed), log it but don't throw - we'll try to get logs
if (waitComplete && phase !== 'Running') {
CloudRunnerLogger.log(`Pod ${podName} completed with phase: ${phase}. Will attempt to retrieve logs.`);
}
if (phase === 'Pending') {
consecutivePendingCount++;
// Check for scheduling failures in events (faster than waiting for conditions)
try {
const events = await kubeClient.listNamespacedEvent(namespace);
const podEvents = events.body.items.filter((x) => x.involvedObject?.name === podName);
const failedSchedulingEvents = podEvents.filter(
(x) => x.reason === 'FailedScheduling' || x.reason === 'SchedulingGated',
);
if (failedSchedulingEvents.length > 0) {
const schedulingMessage = failedSchedulingEvents
.map((x) => `${x.reason}: ${x.message || ''}`)
.join('; ');
message = `Pod ${podName} cannot be scheduled:\n${schedulingMessage}`;
CloudRunnerLogger.logWarning(message);
waitComplete = false;
return true; // Exit wait loop to throw error
}
// Check if pod is actively pulling an image - if so, allow more time
const isPullingImage = podEvents.some(
(x) => x.reason === 'Pulling' || x.reason === 'Pulled' || x.message?.includes('Pulling image'),
);
const hasImagePullError = podEvents.some(
(x) => x.reason === 'Failed' && (x.message?.includes('pull') || x.message?.includes('image')),
);
if (hasImagePullError) {
message = `Pod ${podName} failed to pull image. Check image availability and credentials.`;
CloudRunnerLogger.logWarning(message);
waitComplete = false;
return true; // Exit wait loop to throw error
}
// If actively pulling image, reset pending count to allow more time
// Large images (like Unity 3.9GB) can take 3-5 minutes to pull
if (isPullingImage && consecutivePendingCount > 4) {
CloudRunnerLogger.log(
`Pod ${podName} is pulling image (check ${consecutivePendingCount}). This may take several minutes for large images.`,
);
// Don't increment consecutivePendingCount if we're actively pulling
consecutivePendingCount = Math.max(4, consecutivePendingCount - 1);
}
} catch {
// Ignore event fetch errors
}
// For tests, allow more time if image is being pulled (large images need 5+ minutes)
// Otherwise fail faster if stuck in Pending (2 minutes = 8 checks at 15s interval)
const isTest = process.env['cloudRunnerTests'] === 'true';
const isPullingImage =
containerStatuses.some(
(cs: any) => cs.state?.waiting?.reason === 'ImagePull' || cs.state?.waiting?.reason === 'ErrImagePull',
) || conditions.some((c: any) => c.reason?.includes('Pulling'));
// Allow up to 20 minutes for image pulls in tests (80 checks), 2 minutes otherwise
const maxPendingChecks = isTest && isPullingImage ? 80 : isTest ? 8 : 80;
if (consecutivePendingCount >= maxPendingChecks) {
message = `Pod ${podName} stuck in Pending state for too long (${consecutivePendingCount} checks). This indicates a scheduling problem.`;
// Get events for context
try {
const events = await kubeClient.listNamespacedEvent(namespace);
const podEvents = events.body.items
.filter((x) => x.involvedObject?.name === podName)
.slice(-10)
.map((x) => `${x.type}: ${x.reason} - ${x.message}`);
if (podEvents.length > 0) {
message += `\n\nRecent Events:\n${podEvents.join('\n')}`;
}
// Get pod details to check for scheduling issues
try {
const podStatus = await kubeClient.readNamespacedPodStatus(podName, namespace);
const podSpec = podStatus.body.spec;
const podStatusDetails = podStatus.body.status;
// Check container resource requests
if (podSpec?.containers?.[0]?.resources?.requests) {
const requests = podSpec.containers[0].resources.requests;
message += `\n\nContainer Resource Requests:\n CPU: ${requests.cpu || 'not set'}\n Memory: ${
requests.memory || 'not set'
}\n Ephemeral Storage: ${requests['ephemeral-storage'] || 'not set'}`;
}
// Check node selector and tolerations
if (podSpec?.nodeSelector && Object.keys(podSpec.nodeSelector).length > 0) {
message += `\n\nNode Selector: ${JSON.stringify(podSpec.nodeSelector)}`;
}
if (podSpec?.tolerations && podSpec.tolerations.length > 0) {
message += `\n\nTolerations: ${JSON.stringify(podSpec.tolerations)}`;
}
// Check pod conditions for scheduling issues
if (podStatusDetails?.conditions) {
const allConditions = podStatusDetails.conditions.map(
(c: any) =>
`${c.type}: ${c.status}${c.reason ? ` (${c.reason})` : ''}${
c.message ? ` - ${c.message}` : ''
}`,
);
message += `\n\nPod Conditions:\n${allConditions.join('\n')}`;
const unschedulable = podStatusDetails.conditions.find(
(c: any) => c.type === 'PodScheduled' && c.status === 'False',
);
if (unschedulable) {
message += `\n\nScheduling Issue: ${unschedulable.reason || 'Unknown'} - ${
unschedulable.message || 'No message'
}`;
}
// Check if pod is assigned to a node
message += podStatusDetails?.hostIP
? `\n\nPod assigned to node: ${podStatusDetails.hostIP}`
: `\n\nPod not yet assigned to a node (scheduling pending)`;
}
// Check node resources if pod is assigned
if (podStatusDetails?.hostIP) {
try {
const nodes = await kubeClient.listNode();
const hostIP = podStatusDetails.hostIP;
const assignedNode = nodes.body.items.find((n: any) =>
n.status?.addresses?.some((a: any) => a.address === hostIP),
);
if (assignedNode?.status && assignedNode.metadata?.name) {
const allocatable = assignedNode.status.allocatable || {};
message += `\n\nNode Resources (${assignedNode.metadata.name}):\n Allocatable CPU: ${
allocatable.cpu || 'unknown'
}\n Allocatable Memory: ${allocatable.memory || 'unknown'}\n Allocatable Ephemeral Storage: ${
allocatable['ephemeral-storage'] || 'unknown'
}`;
// Check for taints that might prevent scheduling
if (assignedNode.spec?.taints && assignedNode.spec.taints.length > 0) {
const taints = assignedNode.spec.taints
.map((t: any) => `${t.key}=${t.value}:${t.effect}`)
.join(', ');
message += `\n Node Taints: ${taints}`;
}
}
} catch {
// Ignore node check errors
}
}
} catch {
// Ignore pod status fetch errors
}
} catch {
// Ignore event fetch errors
}
CloudRunnerLogger.logWarning(message);
waitComplete = false;
return true; // Exit wait loop to throw error
}
// Log diagnostic info every 4 checks (1 minute) if still pending
if (consecutivePendingCount % 4 === 0) {
const pendingMessage = `Pod ${podName} still Pending (check ${consecutivePendingCount}/${maxPendingChecks}). Phase: ${phase}`;
const conditionMessages = conditions
.map((c: any) => `${c.type}: ${c.reason || 'N/A'} - ${c.message || 'N/A'}`)
.join('; ');
CloudRunnerLogger.log(`${pendingMessage}. Conditions: ${conditionMessages || 'None'}`);
// Log events periodically to help diagnose
if (consecutivePendingCount % 8 === 0) {
try {
const events = await kubeClient.listNamespacedEvent(namespace);
const podEvents = events.body.items
.filter((x) => x.involvedObject?.name === podName)
.slice(-3)
.map((x) => `${x.type}: ${x.reason} - ${x.message}`)
.join('; ');
if (podEvents) {
CloudRunnerLogger.log(`Recent pod events: ${podEvents}`);
}
} catch {
// Ignore event fetch errors
}
}
}
}
message = `Phase:${phase} \n Reason:${conditions[0]?.reason || ''} \n Message:${
conditions[0]?.message || ''
}`;
if (waitComplete || phase !== 'Pending') return true;
return false;
},
{
timeout: process.env['cloudRunnerTests'] === 'true' ? 300000 : 2000000, // 5 minutes for tests, ~33 minutes for production
intervalBetweenAttempts: 15000, // 15 seconds
},
);
} catch (waitError: any) {
// If waitUntil times out or throws, get final pod status
try {
const finalStatus = await kubeClient.readNamespacedPodStatus(podName, namespace);
const phase = finalStatus?.body.status?.phase || 'Unknown';
const conditions = finalStatus?.body.status?.conditions || [];
message = `Pod ${podName} timed out waiting to start.\nFinal Phase: ${phase}\n`;
message += conditions.map((c: any) => `${c.type}: ${c.reason} - ${c.message}`).join('\n');
// Get events for context
try {
const events = await kubeClient.listNamespacedEvent(namespace);
const podEvents = events.body.items
.filter((x) => x.involvedObject?.name === podName)
.slice(-5)
.map((x) => `${x.type}: ${x.reason} - ${x.message}`);
if (podEvents.length > 0) {
message += `\n\nRecent Events:\n${podEvents.join('\n')}`;
}
} catch {
// Ignore event fetch errors
}
CloudRunnerLogger.logWarning(message);
} catch {
message = `Pod ${podName} timed out and could not retrieve final status: ${waitError?.message || waitError}`;
CloudRunnerLogger.logWarning(message);
}
throw new Error(`Pod ${podName} failed to start within timeout. ${message}`);
return false;
},
{
timeout: 2000000,
intervalBetweenAttempts: 15000,
},
);
if (!success) {
CloudRunnerLogger.log(message);
}
// Only throw if we detected a permanent failure condition
// If the pod completed (Failed/Succeeded), we should still try to get logs
if (!waitComplete) {
// Check the final phase to see if it's a permanent failure or just completed
try {
const finalStatus = await kubeClient.readNamespacedPodStatus(podName, namespace);
const finalPhase = finalStatus?.body.status?.phase || 'Unknown';
if (finalPhase === 'Failed' || finalPhase === 'Succeeded') {
CloudRunnerLogger.logWarning(
`Pod ${podName} completed with phase ${finalPhase} before reaching Running state. Will attempt to retrieve logs.`,
);
return true; // Allow workflow to continue and try to get logs
}
} catch {
// If we can't check status, fall through to throw error
}
CloudRunnerLogger.logWarning(`Pod ${podName} did not reach running state: ${message}`);
throw new Error(`Pod ${podName} did not start successfully: ${message}`);
}
return waitComplete;
return success;
}
}

View File

@@ -6,7 +6,6 @@ import { ProviderInterface } from '../provider-interface';
import CloudRunnerSecret from '../../options/cloud-runner-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { quote } from 'shell-quote';
class LocalCloudRunner implements ProviderInterface {
listResources(): Promise<ProviderResource[]> {
@@ -33,6 +32,8 @@ class LocalCloudRunner implements ProviderInterface {
throw new Error('Method not implemented.');
}
cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
@@ -67,20 +68,6 @@ class LocalCloudRunner implements ProviderInterface {
CloudRunnerLogger.log(buildGuid);
CloudRunnerLogger.log(commands);
// On Windows, many built-in hooks use POSIX shell syntax. Execute via bash if available.
if (process.platform === 'win32') {
const inline = commands
.replace(/\r/g, '')
.split('\n')
.filter((x) => x.trim().length > 0)
.join(' ; ');
// Use shell-quote to properly escape the command string, preventing command injection
const bashWrapped = `bash -lc ${quote([inline])}`;
return await CloudRunnerSystem.Run(bashWrapped);
}
return await CloudRunnerSystem.Run(commands);
}
}

View File

@@ -1,278 +0,0 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import path from 'path';
import CloudRunnerLogger from '../services/core/cloud-runner-logger';
import { GitHubUrlInfo, generateCacheKey } from './provider-url-parser';
const execAsync = promisify(exec);
export interface GitCloneResult {
success: boolean;
localPath: string;
error?: string;
}
export interface GitUpdateResult {
success: boolean;
updated: boolean;
error?: string;
}
/**
* Manages git operations for provider repositories
*/
export class ProviderGitManager {
private static readonly CACHE_DIR = path.join(process.cwd(), '.provider-cache');
private static readonly GIT_TIMEOUT = 30000; // 30 seconds
/**
* Ensures the cache directory exists
*/
private static ensureCacheDir(): void {
if (!fs.existsSync(this.CACHE_DIR)) {
fs.mkdirSync(this.CACHE_DIR, { recursive: true });
CloudRunnerLogger.log(`Created provider cache directory: ${this.CACHE_DIR}`);
}
}
/**
* Gets the local path for a cached repository
* @param urlInfo GitHub URL information
* @returns Local path to the repository
*/
private static getLocalPath(urlInfo: GitHubUrlInfo): string {
const cacheKey = generateCacheKey(urlInfo);
return path.join(this.CACHE_DIR, cacheKey);
}
/**
* Checks if a repository is already cloned locally
* @param urlInfo GitHub URL information
* @returns True if repository exists locally
*/
private static isRepositoryCloned(urlInfo: GitHubUrlInfo): boolean {
const localPath = this.getLocalPath(urlInfo);
return fs.existsSync(localPath) && fs.existsSync(path.join(localPath, '.git'));
}
/**
* Clones a GitHub repository to the local cache
* @param urlInfo GitHub URL information
* @returns Clone result with success status and local path
*/
static async cloneRepository(urlInfo: GitHubUrlInfo): Promise<GitCloneResult> {
this.ensureCacheDir();
const localPath = this.getLocalPath(urlInfo);
// Remove existing directory if it exists
if (fs.existsSync(localPath)) {
CloudRunnerLogger.log(`Removing existing directory: ${localPath}`);
fs.rmSync(localPath, { recursive: true, force: true });
}
try {
CloudRunnerLogger.log(`Cloning repository: ${urlInfo.url} to ${localPath}`);
const cloneCommand = `git clone --depth 1 --branch ${urlInfo.branch} ${urlInfo.url} "${localPath}"`;
CloudRunnerLogger.log(`Executing: ${cloneCommand}`);
const { stderr } = await execAsync(cloneCommand, {
timeout: this.GIT_TIMEOUT,
cwd: this.CACHE_DIR,
});
if (stderr && !stderr.includes('warning')) {
CloudRunnerLogger.log(`Git clone stderr: ${stderr}`);
}
CloudRunnerLogger.log(`Successfully cloned repository to: ${localPath}`);
return {
success: true,
localPath,
};
} catch (error: any) {
const errorMessage = `Failed to clone repository ${urlInfo.url}: ${error.message}`;
CloudRunnerLogger.log(`Error: ${errorMessage}`);
return {
success: false,
localPath,
error: errorMessage,
};
}
}
/**
* Updates a locally cloned repository
* @param urlInfo GitHub URL information
* @returns Update result with success status and whether it was updated
*/
static async updateRepository(urlInfo: GitHubUrlInfo): Promise<GitUpdateResult> {
const localPath = this.getLocalPath(urlInfo);
if (!this.isRepositoryCloned(urlInfo)) {
return {
success: false,
updated: false,
error: 'Repository not found locally',
};
}
try {
CloudRunnerLogger.log(`Updating repository: ${localPath}`);
// Fetch latest changes
await execAsync('git fetch origin', {
timeout: this.GIT_TIMEOUT,
cwd: localPath,
});
// Check if there are updates
const { stdout: statusOutput } = await execAsync(`git status -uno`, {
timeout: this.GIT_TIMEOUT,
cwd: localPath,
});
const hasUpdates =
statusOutput.includes('Your branch is behind') || statusOutput.includes('can be fast-forwarded');
if (hasUpdates) {
CloudRunnerLogger.log(`Updates available, pulling latest changes...`);
// Reset to origin/branch to get latest changes
await execAsync(`git reset --hard origin/${urlInfo.branch}`, {
timeout: this.GIT_TIMEOUT,
cwd: localPath,
});
CloudRunnerLogger.log(`Repository updated successfully`);
return {
success: true,
updated: true,
};
} else {
CloudRunnerLogger.log(`Repository is already up to date`);
return {
success: true,
updated: false,
};
}
} catch (error: any) {
const errorMessage = `Failed to update repository ${localPath}: ${error.message}`;
CloudRunnerLogger.log(`Error: ${errorMessage}`);
return {
success: false,
updated: false,
error: errorMessage,
};
}
}
/**
* Ensures a repository is available locally (clone if needed, update if exists)
* @param urlInfo GitHub URL information
* @returns Local path to the repository
*/
static async ensureRepositoryAvailable(urlInfo: GitHubUrlInfo): Promise<string> {
this.ensureCacheDir();
if (this.isRepositoryCloned(urlInfo)) {
CloudRunnerLogger.log(`Repository already exists locally, checking for updates...`);
const updateResult = await this.updateRepository(urlInfo);
if (!updateResult.success) {
CloudRunnerLogger.log(`Failed to update repository, attempting fresh clone...`);
const cloneResult = await this.cloneRepository(urlInfo);
if (!cloneResult.success) {
throw new Error(`Failed to ensure repository availability: ${cloneResult.error}`);
}
return cloneResult.localPath;
}
return this.getLocalPath(urlInfo);
} else {
CloudRunnerLogger.log(`Repository not found locally, cloning...`);
const cloneResult = await this.cloneRepository(urlInfo);
if (!cloneResult.success) {
throw new Error(`Failed to clone repository: ${cloneResult.error}`);
}
return cloneResult.localPath;
}
}
/**
* Gets the path to the provider module within a repository
* @param urlInfo GitHub URL information
* @param localPath Local path to the repository
* @returns Path to the provider module
*/
static getProviderModulePath(urlInfo: GitHubUrlInfo, localPath: string): string {
if (urlInfo.path) {
return path.join(localPath, urlInfo.path);
}
// Look for common provider entry points
const commonEntryPoints = [
'index.js',
'index.ts',
'src/index.js',
'src/index.ts',
'lib/index.js',
'lib/index.ts',
'dist/index.js',
'dist/index.js.map',
];
for (const entryPoint of commonEntryPoints) {
const fullPath = path.join(localPath, entryPoint);
if (fs.existsSync(fullPath)) {
CloudRunnerLogger.log(`Found provider entry point: ${entryPoint}`);
return fullPath;
}
}
// Default to repository root
CloudRunnerLogger.log(`No specific entry point found, using repository root`);
return localPath;
}
/**
* Cleans up old cached repositories (optional maintenance)
* @param maxAgeDays Maximum age in days for cached repositories
*/
static async cleanupOldRepositories(maxAgeDays: number = 30): Promise<void> {
this.ensureCacheDir();
try {
const entries = fs.readdirSync(this.CACHE_DIR, { withFileTypes: true });
const now = Date.now();
const maxAge = maxAgeDays * 24 * 60 * 60 * 1000; // Convert to milliseconds
for (const entry of entries) {
if (entry.isDirectory()) {
const entryPath = path.join(this.CACHE_DIR, entry.name);
const stats = fs.statSync(entryPath);
if (now - stats.mtime.getTime() > maxAge) {
CloudRunnerLogger.log(`Cleaning up old repository: ${entry.name}`);
fs.rmSync(entryPath, { recursive: true, force: true });
}
}
}
} catch (error: any) {
CloudRunnerLogger.log(`Error during cleanup: ${error.message}`);
}
}
}

View File

@@ -6,6 +6,8 @@ import { ProviderWorkflow } from './provider-workflow';
export interface ProviderInterface {
cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars

View File

@@ -1,158 +0,0 @@
import { ProviderInterface } from './provider-interface';
import BuildParameters from '../../build-parameters';
import CloudRunnerLogger from '../services/core/cloud-runner-logger';
import { parseProviderSource, logProviderSource, ProviderSourceInfo } from './provider-url-parser';
import { ProviderGitManager } from './provider-git-manager';
// import path from 'path'; // Not currently used
/**
* Dynamically load a provider package by name, URL, or path.
* @param providerSource Provider source (name, URL, or path)
* @param buildParameters Build parameters passed to the provider constructor
* @throws Error when the provider cannot be loaded or does not implement ProviderInterface
*/
export default async function loadProvider(
providerSource: string,
buildParameters: BuildParameters,
): Promise<ProviderInterface> {
CloudRunnerLogger.log(`Loading provider: ${providerSource}`);
// Parse the provider source to determine its type
const sourceInfo = parseProviderSource(providerSource);
logProviderSource(providerSource, sourceInfo);
let modulePath: string;
let importedModule: any;
try {
// Handle different source types
switch (sourceInfo.type) {
case 'github': {
CloudRunnerLogger.log(`Processing GitHub repository: ${sourceInfo.owner}/${sourceInfo.repo}`);
// Ensure the repository is available locally
const localRepoPath = await ProviderGitManager.ensureRepositoryAvailable(sourceInfo);
// Get the path to the provider module within the repository
modulePath = ProviderGitManager.getProviderModulePath(sourceInfo, localRepoPath);
CloudRunnerLogger.log(`Loading provider from: ${modulePath}`);
break;
}
case 'local': {
modulePath = sourceInfo.path;
CloudRunnerLogger.log(`Loading provider from local path: ${modulePath}`);
break;
}
case 'npm': {
modulePath = sourceInfo.packageName;
CloudRunnerLogger.log(`Loading provider from NPM package: ${modulePath}`);
break;
}
default: {
// Fallback to built-in providers or direct import
const providerModuleMap: Record<string, string> = {
aws: './aws',
k8s: './k8s',
test: './test',
'local-docker': './docker',
'local-system': './local',
local: './local',
};
modulePath = providerModuleMap[providerSource] || providerSource;
CloudRunnerLogger.log(`Loading provider from module path: ${modulePath}`);
break;
}
}
// Import the module
importedModule = await import(modulePath);
} catch (error) {
throw new Error(`Failed to load provider package '${providerSource}': ${(error as Error).message}`);
}
// Extract the provider class/function
const Provider = importedModule.default || importedModule;
// Validate that we have a constructor
if (typeof Provider !== 'function') {
throw new TypeError(`Provider package '${providerSource}' does not export a constructor function`);
}
// Instantiate the provider
let instance: any;
try {
instance = new Provider(buildParameters);
} catch (error) {
throw new Error(`Failed to instantiate provider '${providerSource}': ${(error as Error).message}`);
}
// Validate that the instance implements the required interface
const requiredMethods = [
'cleanupWorkflow',
'setupWorkflow',
'runTaskInWorkflow',
'garbageCollect',
'listResources',
'listWorkflow',
'watchWorkflow',
];
for (const method of requiredMethods) {
if (typeof instance[method] !== 'function') {
throw new TypeError(
`Provider package '${providerSource}' does not implement ProviderInterface. Missing method '${method}'.`,
);
}
}
CloudRunnerLogger.log(`Successfully loaded provider: ${providerSource}`);
return instance as ProviderInterface;
}
/**
* ProviderLoader class for backward compatibility and additional utilities
*/
export class ProviderLoader {
/**
* Dynamically loads a provider by name, URL, or path (wrapper around loadProvider function)
* @param providerSource - The provider source (name, URL, or path) to load
* @param buildParameters - Build parameters to pass to the provider constructor
* @returns Promise<ProviderInterface> - The loaded provider instance
* @throws Error if provider package is missing or doesn't implement ProviderInterface
*/
static async loadProvider(providerSource: string, buildParameters: BuildParameters): Promise<ProviderInterface> {
return loadProvider(providerSource, buildParameters);
}
/**
* Gets a list of available provider names
* @returns string[] - Array of available provider names
*/
static getAvailableProviders(): string[] {
return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local'];
}
/**
* Cleans up old cached repositories
* @param maxAgeDays Maximum age in days for cached repositories (default: 30)
*/
static async cleanupCache(maxAgeDays: number = 30): Promise<void> {
await ProviderGitManager.cleanupOldRepositories(maxAgeDays);
}
/**
* Gets information about a provider source without loading it
* @param providerSource The provider source to analyze
* @returns ProviderSourceInfo object with parsed details
*/
static analyzeProviderSource(providerSource: string): ProviderSourceInfo {
return parseProviderSource(providerSource);
}
}

View File

@@ -1,138 +0,0 @@
import CloudRunnerLogger from '../services/core/cloud-runner-logger';
export interface GitHubUrlInfo {
type: 'github';
owner: string;
repo: string;
branch?: string;
path?: string;
url: string;
}
export interface LocalPathInfo {
type: 'local';
path: string;
}
export interface NpmPackageInfo {
type: 'npm';
packageName: string;
}
export type ProviderSourceInfo = GitHubUrlInfo | LocalPathInfo | NpmPackageInfo;
/**
* Parses a provider source string and determines its type and details
* @param source The provider source string (URL, path, or package name)
* @returns ProviderSourceInfo object with parsed details
*/
export function parseProviderSource(source: string): ProviderSourceInfo {
// Check if it's a GitHub URL
const githubMatch = source.match(
/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?(?:tree\/([^/]+))?(?:\/(.+))?$/,
);
if (githubMatch) {
const [, owner, repo, branch, path] = githubMatch;
return {
type: 'github',
owner,
repo,
branch: branch || 'main',
path: path || '',
url: `https://github.com/${owner}/${repo}`,
};
}
// Check if it's a GitHub SSH URL
const githubSshMatch = source.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?\/?(?:tree\/([^/]+))?(?:\/(.+))?$/);
if (githubSshMatch) {
const [, owner, repo, branch, path] = githubSshMatch;
return {
type: 'github',
owner,
repo,
branch: branch || 'main',
path: path || '',
url: `https://github.com/${owner}/${repo}`,
};
}
// Check if it's a shorthand GitHub reference (owner/repo)
const shorthandMatch = source.match(/^([^/@]+)\/([^/@]+)(?:@([^/]+))?(?:\/(.+))?$/);
if (shorthandMatch && !source.startsWith('.') && !source.startsWith('/') && !source.includes('\\')) {
const [, owner, repo, branch, path] = shorthandMatch;
return {
type: 'github',
owner,
repo,
branch: branch || 'main',
path: path || '',
url: `https://github.com/${owner}/${repo}`,
};
}
// Check if it's a local path
if (source.startsWith('./') || source.startsWith('../') || source.startsWith('/') || source.includes('\\')) {
return {
type: 'local',
path: source,
};
}
// Default to npm package
return {
type: 'npm',
packageName: source,
};
}
/**
* Generates a cache key for a GitHub repository
* @param urlInfo GitHub URL information
* @returns Cache key string
*/
export function generateCacheKey(urlInfo: GitHubUrlInfo): string {
return `github_${urlInfo.owner}_${urlInfo.repo}_${urlInfo.branch}`.replace(/[^\w-]/g, '_');
}
/**
* Validates if a string looks like a valid GitHub URL or reference
* @param source The source string to validate
* @returns True if it looks like a GitHub reference
*/
export function isGitHubSource(source: string): boolean {
const parsed = parseProviderSource(source);
return parsed.type === 'github';
}
/**
* Logs the parsed provider source information
* @param source The original source string
* @param parsed The parsed source information
*/
export function logProviderSource(source: string, parsed: ProviderSourceInfo): void {
CloudRunnerLogger.log(`Provider source: ${source}`);
switch (parsed.type) {
case 'github':
CloudRunnerLogger.log(` Type: GitHub repository`);
CloudRunnerLogger.log(` Owner: ${parsed.owner}`);
CloudRunnerLogger.log(` Repository: ${parsed.repo}`);
CloudRunnerLogger.log(` Branch: ${parsed.branch}`);
if (parsed.path) {
CloudRunnerLogger.log(` Path: ${parsed.path}`);
}
break;
case 'local':
CloudRunnerLogger.log(` Type: Local path`);
CloudRunnerLogger.log(` Path: ${parsed.path}`);
break;
case 'npm':
CloudRunnerLogger.log(` Type: NPM package`);
CloudRunnerLogger.log(` Package: ${parsed.packageName}`);
break;
}
}

View File

@@ -25,6 +25,8 @@ class TestCloudRunner implements ProviderInterface {
throw new Error('Method not implemented.');
}
cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars

View File

@@ -79,232 +79,12 @@ export class Caching {
return;
}
// Check disk space before creating tar archive and clean up if needed
let diskUsagePercent = 0;
try {
const diskCheckOutput = await CloudRunnerSystem.Run(`df . 2>/dev/null || df /data 2>/dev/null || true`);
CloudRunnerLogger.log(`Disk space before tar: ${diskCheckOutput}`);
// Parse disk usage percentage (e.g., "72G 72G 196M 100%")
const usageMatch = diskCheckOutput.match(/(\d+)%/);
if (usageMatch) {
diskUsagePercent = Number.parseInt(usageMatch[1], 10);
}
} catch {
// Ignore disk check errors
}
// If disk usage is high (>90%), proactively clean up old cache files
if (diskUsagePercent > 90) {
CloudRunnerLogger.log(`Disk usage is ${diskUsagePercent}% - cleaning up old cache files before tar operation`);
try {
const cacheParent = path.dirname(cacheFolder);
if (await fileExists(cacheParent)) {
// Try to fix permissions first to avoid permission denied errors
await CloudRunnerSystem.Run(
`chmod -R u+w ${cacheParent} 2>/dev/null || chown -R $(whoami) ${cacheParent} 2>/dev/null || true`,
);
// Remove cache files older than 6 hours (more aggressive than 1 day)
// Use multiple methods to handle permission issues
await CloudRunnerSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +360 -delete 2>/dev/null || true`,
);
// Try with sudo if available
await CloudRunnerSystem.Run(
`sudo find ${cacheParent} -name "*.tar*" -type f -mmin +360 -delete 2>/dev/null || true`,
);
// As last resort, try to remove files one by one
await CloudRunnerSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +360 -exec rm -f {} + 2>/dev/null || true`,
);
// Also try to remove old cache directories
await CloudRunnerSystem.Run(`find ${cacheParent} -type d -empty -delete 2>/dev/null || true`);
// If disk is still very high (>95%), be even more aggressive
if (diskUsagePercent > 95) {
CloudRunnerLogger.log(`Disk usage is very high (${diskUsagePercent}%), performing aggressive cleanup...`);
// Remove files older than 1 hour
await CloudRunnerSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
);
await CloudRunnerSystem.Run(
`sudo find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
);
}
CloudRunnerLogger.log(`Cleanup completed. Checking disk space again...`);
const diskCheckAfter = await CloudRunnerSystem.Run(`df . 2>/dev/null || df /data 2>/dev/null || true`);
CloudRunnerLogger.log(`Disk space after cleanup: ${diskCheckAfter}`);
// Check disk usage again after cleanup
let diskUsageAfterCleanup = 0;
try {
const usageMatchAfter = diskCheckAfter.match(/(\d+)%/);
if (usageMatchAfter) {
diskUsageAfterCleanup = Number.parseInt(usageMatchAfter[1], 10);
}
} catch {
// Ignore parsing errors
}
// If disk is still at 100% after cleanup, skip tar operation to prevent hang.
// Do NOT fail the build here it's better to skip caching than to fail the job
// due to shared CI disk pressure.
if (diskUsageAfterCleanup >= 100) {
const message = `Cannot create cache archive: disk is still at ${diskUsageAfterCleanup}% after cleanup. Tar operation would hang. Skipping cache push; please free up disk space manually if this persists.`;
CloudRunnerLogger.logWarning(message);
RemoteClientLogger.log(message);
// Restore working directory before early return
process.chdir(`${startPath}`);
return;
}
}
} catch (cleanupError) {
// If cleanupError is our disk space error, rethrow it
if (cleanupError instanceof Error && cleanupError.message.includes('Cannot create cache archive')) {
throw cleanupError;
}
CloudRunnerLogger.log(`Proactive cleanup failed: ${cleanupError}`);
}
}
// Clean up any existing incomplete tar files
try {
await CloudRunnerSystem.Run(`rm -f ${cacheArtifactName}.tar${compressionSuffix} 2>/dev/null || true`);
} catch {
// Ignore cleanup errors
}
try {
// Add timeout to tar command to prevent hanging when disk is full
// Use timeout command with 10 minute limit (600 seconds) if available
// Check if timeout command exists, otherwise use regular tar
const tarCommand = `tar -cf ${cacheArtifactName}.tar${compressionSuffix} "${path.basename(sourceFolder)}"`;
let tarCommandToRun = tarCommand;
try {
// Check if timeout command is available
await CloudRunnerSystem.Run(`which timeout > /dev/null 2>&1`, true, true);
// Use timeout if available (600 seconds = 10 minutes)
tarCommandToRun = `timeout 600 ${tarCommand}`;
} catch {
// timeout command not available, use regular tar
// Note: This could still hang if disk is full, but the disk space check above should prevent this
tarCommandToRun = tarCommand;
}
await CloudRunnerSystem.Run(tarCommandToRun);
} catch (error: any) {
// Check if error is due to disk space or timeout
const errorMessage = error?.message || error?.toString() || '';
if (
errorMessage.includes('No space left') ||
errorMessage.includes('Wrote only') ||
errorMessage.includes('timeout') ||
errorMessage.includes('Terminated')
) {
CloudRunnerLogger.log(`Disk space error detected. Attempting aggressive cleanup...`);
// Try to clean up old cache files more aggressively
try {
const cacheParent = path.dirname(cacheFolder);
if (await fileExists(cacheParent)) {
// Try to fix permissions first to avoid permission denied errors
await CloudRunnerSystem.Run(
`chmod -R u+w ${cacheParent} 2>/dev/null || chown -R $(whoami) ${cacheParent} 2>/dev/null || true`,
);
// Remove cache files older than 1 hour (very aggressive)
// Use multiple methods to handle permission issues
await CloudRunnerSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
);
await CloudRunnerSystem.Run(
`sudo find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
);
// As last resort, try to remove files one by one
await CloudRunnerSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +60 -exec rm -f {} + 2>/dev/null || true`,
);
// Remove empty cache directories
await CloudRunnerSystem.Run(`find ${cacheParent} -type d -empty -delete 2>/dev/null || true`);
// Also try to clean up the entire cache folder if it's getting too large
const cacheRoot = path.resolve(cacheParent, '..');
if (await fileExists(cacheRoot)) {
// Try to fix permissions for cache root too
await CloudRunnerSystem.Run(
`chmod -R u+w ${cacheRoot} 2>/dev/null || chown -R $(whoami) ${cacheRoot} 2>/dev/null || true`,
);
// Remove cache entries older than 30 minutes
await CloudRunnerSystem.Run(
`find ${cacheRoot} -name "*.tar*" -type f -mmin +30 -delete 2>/dev/null || true`,
);
await CloudRunnerSystem.Run(
`sudo find ${cacheRoot} -name "*.tar*" -type f -mmin +30 -delete 2>/dev/null || true`,
);
}
CloudRunnerLogger.log(`Aggressive cleanup completed. Retrying tar operation...`);
// Retry the tar operation once after cleanup
let retrySucceeded = false;
try {
await CloudRunnerSystem.Run(
`tar -cf ${cacheArtifactName}.tar${compressionSuffix} "${path.basename(sourceFolder)}"`,
);
// If retry succeeds, mark it - we'll continue normally without throwing
retrySucceeded = true;
} catch (retryError: any) {
throw new Error(
`Failed to create cache archive after cleanup. Original error: ${errorMessage}. Retry error: ${
retryError?.message || retryError
}`,
);
}
// If retry succeeded, don't throw the original error - let execution continue after catch block
if (!retrySucceeded) {
throw error;
}
// If we get here, retry succeeded - execution will continue after the catch block
} else {
throw new Error(
`Failed to create cache archive due to insufficient disk space. Error: ${errorMessage}. Cleanup not possible - cache folder missing.`,
);
}
} catch (cleanupError: any) {
CloudRunnerLogger.log(`Cleanup attempt failed: ${cleanupError}`);
throw new Error(
`Failed to create cache archive due to insufficient disk space. Error: ${errorMessage}. Cleanup failed: ${
cleanupError?.message || cleanupError
}`,
);
}
} else {
throw error;
}
}
await CloudRunnerSystem.Run(
`tar -cf ${cacheArtifactName}.tar${compressionSuffix} ${path.basename(sourceFolder)}`,
);
await CloudRunnerSystem.Run(`du ${cacheArtifactName}.tar${compressionSuffix}`);
assert(await fileExists(`${cacheArtifactName}.tar${compressionSuffix}`), 'cache archive exists');
assert(await fileExists(path.basename(sourceFolder)), 'source folder exists');
// Ensure the cache folder directory exists before moving the file
// (it might have been deleted by cleanup if it was empty)
if (!(await fileExists(cacheFolder))) {
await CloudRunnerSystem.Run(`mkdir -p ${cacheFolder}`);
}
await CloudRunnerSystem.Run(`mv ${cacheArtifactName}.tar${compressionSuffix} ${cacheFolder}`);
RemoteClientLogger.log(`moved cache entry ${cacheArtifactName} to ${cacheFolder}`);
assert(
@@ -355,91 +135,11 @@ export class Caching {
await CloudRunnerLogger.log(`cache key ${cacheArtifactName} selection ${cacheSelection}`);
if (await fileExists(`${cacheSelection}.tar${compressionSuffix}`)) {
// Check disk space before extraction to prevent hangs
let diskUsagePercent = 0;
try {
const diskCheckOutput = await CloudRunnerSystem.Run(`df . 2>/dev/null || df /data 2>/dev/null || true`);
const usageMatch = diskCheckOutput.match(/(\d+)%/);
if (usageMatch) {
diskUsagePercent = Number.parseInt(usageMatch[1], 10);
}
} catch {
// Ignore disk check errors
}
// If disk is at 100%, skip cache extraction to prevent hangs
if (diskUsagePercent >= 100) {
const message = `Disk is at ${diskUsagePercent}% - skipping cache extraction to prevent hang. Cache may be incomplete or corrupted.`;
CloudRunnerLogger.logWarning(message);
RemoteClientLogger.logWarning(message);
// Continue without cache - build will proceed without cached Library
process.chdir(startPath);
return;
}
// Validate tar file integrity before extraction
try {
// Use tar -t to test the archive without extracting (fast check)
// This will fail if the archive is corrupted
await CloudRunnerSystem.Run(
`tar -tf ${cacheSelection}.tar${compressionSuffix} > /dev/null 2>&1 || (echo "Tar file validation failed" && exit 1)`,
);
} catch {
const message = `Cache archive ${cacheSelection}.tar${compressionSuffix} appears to be corrupted or incomplete. Skipping cache extraction.`;
CloudRunnerLogger.logWarning(message);
RemoteClientLogger.logWarning(message);
// Continue without cache - build will proceed without cached Library
process.chdir(startPath);
return;
}
const resultsFolder = `results${CloudRunner.buildParameters.buildGuid}`;
await CloudRunnerSystem.Run(`mkdir -p ${resultsFolder}`);
RemoteClientLogger.log(`cache item exists ${cacheFolder}/${cacheSelection}.tar${compressionSuffix}`);
const fullResultsFolder = path.join(cacheFolder, resultsFolder);
// Extract with timeout to prevent infinite hangs
try {
let tarExtractCommand = `tar -xf ${cacheSelection}.tar${compressionSuffix} -C ${fullResultsFolder}`;
// Add timeout if available (600 seconds = 10 minutes)
try {
await CloudRunnerSystem.Run(`which timeout > /dev/null 2>&1`, true, true);
tarExtractCommand = `timeout 600 ${tarExtractCommand}`;
} catch {
// timeout command not available, use regular tar
}
await CloudRunnerSystem.Run(tarExtractCommand);
} catch (extractError: any) {
const errorMessage = extractError?.message || extractError?.toString() || '';
// Check for common tar errors that indicate corruption or disk issues
if (
errorMessage.includes('Unexpected EOF') ||
errorMessage.includes('rmtlseek') ||
errorMessage.includes('No space left') ||
errorMessage.includes('timeout') ||
errorMessage.includes('Terminated')
) {
const message = `Cache extraction failed (likely due to corrupted archive or disk space): ${errorMessage}. Continuing without cache.`;
CloudRunnerLogger.logWarning(message);
RemoteClientLogger.logWarning(message);
// Continue without cache - build will proceed without cached Library
process.chdir(startPath);
return;
}
// Re-throw other errors
throw extractError;
}
await CloudRunnerSystem.Run(`tar -xf ${cacheSelection}.tar${compressionSuffix} -C ${fullResultsFolder}`);
RemoteClientLogger.log(`cache item extracted to ${fullResultsFolder}`);
assert(await fileExists(fullResultsFolder), `cache extraction results folder exists`);
const destinationParentFolder = path.resolve(destinationFolder, '..');

View File

@@ -12,206 +12,16 @@ import { CloudRunnerSystem } from '../services/core/cloud-runner-system';
import YAML from 'yaml';
import GitHub from '../../github';
import BuildParameters from '../../build-parameters';
import { Cli } from '../../cli/cli';
import CloudRunnerOptions from '../options/cloud-runner-options';
import ResourceTracking from '../services/core/resource-tracking';
export class RemoteClient {
@CliFunction(`remote-cli-pre-build`, `sets up a repository, usually before a game-ci build`)
static async setupRemoteClient() {
static async runRemoteClientJob() {
CloudRunnerLogger.log(`bootstrap game ci cloud runner...`);
await ResourceTracking.logDiskUsageSnapshot('remote-cli-pre-build (start)');
if (!(await RemoteClient.handleRetainedWorkspace())) {
await RemoteClient.bootstrapRepository();
}
await RemoteClient.replaceLargePackageReferencesWithSharedReferences();
await RemoteClient.runCustomHookFiles(`before-build`);
}
@CliFunction('remote-cli-log-stream', `log stream from standard input`)
public static async remoteClientLogStream() {
const logFile = Cli.options!['logFile'];
process.stdin.resume();
process.stdin.setEncoding('utf8');
// For K8s, ensure stdout is unbuffered so messages are captured immediately
if (CloudRunnerOptions.providerStrategy === 'k8s') {
process.stdout.setDefaultEncoding('utf8');
}
let lingeringLine = '';
process.stdin.on('data', (chunk) => {
const lines = chunk.toString().split('\n');
lines[0] = lingeringLine + lines[0];
lingeringLine = lines.pop() || '';
for (const element of lines) {
// Always write to log file so output can be collected by providers
if (element.trim()) {
fs.appendFileSync(logFile, `${element}\n`);
}
// For K8s, also write to stdout so kubectl logs can capture it
if (CloudRunnerOptions.providerStrategy === 'k8s') {
// Write to stdout so kubectl logs can capture it - ensure newline is included
// Stdout flushes automatically on newline, so no explicit flush needed
process.stdout.write(`${element}\n`);
}
CloudRunnerLogger.log(element);
}
});
process.stdin.on('end', () => {
if (lingeringLine) {
// Always write to log file so output can be collected by providers
fs.appendFileSync(logFile, `${lingeringLine}\n`);
// For K8s, also write to stdout so kubectl logs can capture it
if (CloudRunnerOptions.providerStrategy === 'k8s') {
// Stdout flushes automatically on newline
process.stdout.write(`${lingeringLine}\n`);
}
}
CloudRunnerLogger.log(lingeringLine);
});
}
@CliFunction(`remote-cli-post-build`, `runs a cloud runner build`)
public static async remoteClientPostBuild(): Promise<string> {
try {
RemoteClientLogger.log(`Running POST build tasks`);
// Ensure cache key is present in logs for assertions
RemoteClientLogger.log(`CACHE_KEY=${CloudRunner.buildParameters.cacheKey}`);
CloudRunnerLogger.log(`${CloudRunner.buildParameters.cacheKey}`);
// Guard: only push Library cache if the folder exists and has contents
try {
const libraryFolderHost = CloudRunnerFolders.libraryFolderAbsolute;
if (fs.existsSync(libraryFolderHost)) {
let libraryEntries: string[] = [];
try {
libraryEntries = await fs.promises.readdir(libraryFolderHost);
} catch {
libraryEntries = [];
}
if (libraryEntries.length > 0) {
await Caching.PushToCache(
CloudRunnerFolders.ToLinuxFolder(`${CloudRunnerFolders.cacheFolderForCacheKeyFull}/Library`),
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.libraryFolderAbsolute),
`lib-${CloudRunner.buildParameters.buildGuid}`,
);
} else {
RemoteClientLogger.log(`Skipping Library cache push (folder is empty)`);
}
} else {
RemoteClientLogger.log(`Skipping Library cache push (folder missing)`);
}
} catch (error: any) {
RemoteClientLogger.logWarning(`Library cache push skipped with error: ${error.message}`);
}
// Guard: only push Build cache if the folder exists and has contents
try {
const buildFolderHost = CloudRunnerFolders.projectBuildFolderAbsolute;
if (fs.existsSync(buildFolderHost)) {
let buildEntries: string[] = [];
try {
buildEntries = await fs.promises.readdir(buildFolderHost);
} catch {
buildEntries = [];
}
if (buildEntries.length > 0) {
await Caching.PushToCache(
CloudRunnerFolders.ToLinuxFolder(`${CloudRunnerFolders.cacheFolderForCacheKeyFull}/build`),
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.projectBuildFolderAbsolute),
`build-${CloudRunner.buildParameters.buildGuid}`,
);
} else {
RemoteClientLogger.log(`Skipping Build cache push (folder is empty)`);
}
} else {
RemoteClientLogger.log(`Skipping Build cache push (folder missing)`);
}
} catch (error: any) {
RemoteClientLogger.logWarning(`Build cache push skipped with error: ${error.message}`);
}
if (!BuildParameters.shouldUseRetainedWorkspaceMode(CloudRunner.buildParameters)) {
const uniqueJobFolderLinux = CloudRunnerFolders.ToLinuxFolder(
CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute,
);
if (
fs.existsSync(CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute) ||
fs.existsSync(uniqueJobFolderLinux)
) {
await CloudRunnerSystem.Run(`rm -r ${uniqueJobFolderLinux} || true`);
} else {
RemoteClientLogger.log(`Skipping cleanup; unique job folder missing`);
}
}
await RemoteClient.runCustomHookFiles(`after-build`);
// WIP - need to give the pod permissions to create config map
await RemoteClientLogger.handleLogManagementPostJob();
} catch (error: any) {
// Log error but don't fail - post-build tasks are best-effort
RemoteClientLogger.logWarning(`Post-build task error: ${error.message}`);
CloudRunnerLogger.log(`Post-build task error: ${error.message}`);
}
// Ensure success marker is always present in logs for tests, even if post-build tasks failed
// For K8s, kubectl logs reads from stdout/stderr, so we must write to stdout
// For all providers, we write to stdout so it gets piped through the log stream
// The log stream will capture it and add it to BuildResults
const successMessage = `Activation successful`;
// Write directly to log file first to ensure it's captured even if pipe fails
// This is critical for all providers, especially K8s where timing matters
try {
const logFilePath = CloudRunner.isCloudRunnerEnvironment
? `/home/job-log.txt`
: path.join(process.cwd(), 'temp', 'job-log.txt');
if (fs.existsSync(path.dirname(logFilePath))) {
fs.appendFileSync(logFilePath, `${successMessage}\n`);
}
} catch {
// If direct file write fails, continue with other methods
}
// Write to stdout so it gets piped through remote-cli-log-stream when invoked via pipe
// This ensures the message is captured in BuildResults for all providers
// Use synchronous write and ensure newline is included for proper flushing
process.stdout.write(`${successMessage}\n`, 'utf8');
// For K8s, also write to stderr as a backup since kubectl logs reads from both stdout and stderr
// This ensures the message is captured even if stdout pipe has issues
if (CloudRunnerOptions.providerStrategy === 'k8s') {
process.stderr.write(`${successMessage}\n`, 'utf8');
}
// Ensure stdout is flushed before process exits (critical for K8s where process might exit quickly)
// For non-TTY streams, we need to explicitly ensure the write completes
if (!process.stdout.isTTY) {
// Give the pipe a moment to process the write
await new Promise((resolve) => setTimeout(resolve, 100));
}
// Also log via CloudRunnerLogger and RemoteClientLogger for GitHub Actions and log file
// This ensures the message appears in log files for providers that read from log files
// RemoteClientLogger.log writes directly to the log file, which is important for providers
// that read from the log file rather than stdout
RemoteClientLogger.log(successMessage);
CloudRunnerLogger.log(successMessage);
await ResourceTracking.logDiskUsageSnapshot('remote-cli-post-build (end)');
return new Promise((result) => result(``));
}
static async runCustomHookFiles(hookLifecycle: string) {
RemoteClientLogger.log(`RunCustomHookFiles: ${hookLifecycle}`);
const gameCiCustomHooksPath = path.join(CloudRunnerFolders.repoPathAbsolute, `game-ci`, `hooks`);
@@ -237,6 +47,7 @@ export class RemoteClient {
`mkdir -p ${CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.cacheFolderForCacheKeyFull)}`,
);
await RemoteClient.cloneRepoWithoutLFSFiles();
await RemoteClient.replaceLargePackageReferencesWithSharedReferences();
await RemoteClient.sizeOfFolder(
'repo before lfs cache pull',
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.repoPathAbsolute),
@@ -306,11 +117,8 @@ export class RemoteClient {
await CloudRunnerSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
await CloudRunnerSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
try {
const depthArgument = CloudRunnerOptions.cloneDepth !== '0' ? `--depth ${CloudRunnerOptions.cloneDepth}` : '';
await CloudRunnerSystem.Run(
`git clone ${depthArgument} ${CloudRunnerFolders.targetBuildRepoUrl} ${path.basename(
CloudRunnerFolders.repoPathAbsolute,
)}`.trim(),
`git clone ${CloudRunnerFolders.targetBuildRepoUrl} ${path.basename(CloudRunnerFolders.repoPathAbsolute)}`,
);
} catch (error: any) {
throw error;
@@ -319,44 +127,10 @@ export class RemoteClient {
await CloudRunnerSystem.Run(`git lfs install`);
assert(fs.existsSync(`.git`), 'git folder exists');
RemoteClientLogger.log(`${CloudRunner.buildParameters.branch}`);
// Ensure refs exist (tags and PR refs)
await CloudRunnerSystem.Run(`git fetch --all --tags || true`);
if ((CloudRunner.buildParameters.branch || '').startsWith('pull/')) {
await CloudRunnerSystem.Run(`git fetch origin +refs/pull/*:refs/remotes/origin/pull/* || true`);
}
const targetSha = CloudRunner.buildParameters.gitSha;
const targetBranch = CloudRunner.buildParameters.branch;
if (targetSha) {
try {
await CloudRunnerSystem.Run(`git checkout ${targetSha}`);
} catch {
try {
await CloudRunnerSystem.Run(`git fetch origin ${targetSha} || true`);
await CloudRunnerSystem.Run(`git checkout ${targetSha}`);
} catch (error) {
RemoteClientLogger.logWarning(`Falling back to branch checkout; SHA not found: ${targetSha}`);
try {
await CloudRunnerSystem.Run(`git checkout ${targetBranch}`);
} catch {
if ((targetBranch || '').startsWith('pull/')) {
await CloudRunnerSystem.Run(`git checkout origin/${targetBranch}`);
} else {
throw error;
}
}
}
}
if (CloudRunner.buildParameters.gitSha !== undefined) {
await CloudRunnerSystem.Run(`git checkout ${CloudRunner.buildParameters.gitSha}`);
} else {
try {
await CloudRunnerSystem.Run(`git checkout ${targetBranch}`);
} catch (_error) {
if ((targetBranch || '').startsWith('pull/')) {
await CloudRunnerSystem.Run(`git checkout origin/${targetBranch}`);
} else {
throw _error;
}
}
await CloudRunnerSystem.Run(`git checkout ${CloudRunner.buildParameters.branch}`);
RemoteClientLogger.log(`buildParameter Git Sha is empty`);
}
@@ -381,76 +155,16 @@ export class RemoteClient {
process.chdir(CloudRunnerFolders.repoPathAbsolute);
await CloudRunnerSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge -- %f"`);
await CloudRunnerSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process"`);
if (CloudRunner.buildParameters.skipLfs) {
RemoteClientLogger.log(`Skipping LFS pull (skipLfs=true)`);
return;
if (!CloudRunner.buildParameters.skipLfs) {
await CloudRunnerSystem.Run(`git lfs pull`);
RemoteClientLogger.log(`pulled latest LFS files`);
assert(fs.existsSync(CloudRunnerFolders.lfsFolderAbsolute));
}
// Best effort: try plain pull first (works for public repos or pre-configured auth)
try {
await CloudRunnerSystem.Run(`git lfs pull`, true);
await CloudRunnerSystem.Run(`git lfs checkout || true`, true);
RemoteClientLogger.log(`Pulled LFS files without explicit token configuration`);
return;
} catch {
/* no-op: best-effort git lfs pull without tokens may fail */
void 0;
}
// Try with GIT_PRIVATE_TOKEN
try {
const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN;
if (gitPrivateToken) {
RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`);
await CloudRunnerSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
await CloudRunnerSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
await CloudRunnerSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
await CloudRunnerSystem.Run(
`git config --global url."https://${gitPrivateToken}@github.com/".insteadOf "https://github.com/"`,
);
await CloudRunnerSystem.Run(`git lfs pull`, true);
await CloudRunnerSystem.Run(`git lfs checkout || true`, true);
RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
return;
}
} catch (error: any) {
RemoteClientLogger.logCliError(`Failed with GIT_PRIVATE_TOKEN: ${error.message}`);
}
// Try with GITHUB_TOKEN
try {
const githubToken = process.env.GITHUB_TOKEN;
if (githubToken) {
RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`);
await CloudRunnerSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
await CloudRunnerSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
await CloudRunnerSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
await CloudRunnerSystem.Run(
`git config --global url."https://${githubToken}@github.com/".insteadOf "https://github.com/"`,
);
await CloudRunnerSystem.Run(`git lfs pull`, true);
await CloudRunnerSystem.Run(`git lfs checkout || true`, true);
RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
return;
}
} catch (error: any) {
RemoteClientLogger.logCliError(`Failed with GITHUB_TOKEN: ${error.message}`);
}
// If we get here, all strategies failed; continue without failing the build
RemoteClientLogger.logWarning(`Proceeding without LFS files (no tokens or pull failed)`);
}
static async handleRetainedWorkspace() {
RemoteClientLogger.log(
`Retained Workspace: ${BuildParameters.shouldUseRetainedWorkspaceMode(CloudRunner.buildParameters)}`,
);
// Log cache key explicitly to aid debugging and assertions
CloudRunnerLogger.log(`Cache Key: ${CloudRunner.buildParameters.cacheKey}`);
if (
BuildParameters.shouldUseRetainedWorkspaceMode(CloudRunner.buildParameters) &&
fs.existsSync(CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute)) &&
@@ -458,29 +172,10 @@ export class RemoteClient {
) {
CloudRunnerLogger.log(`Retained Workspace Already Exists!`);
process.chdir(CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.repoPathAbsolute));
await CloudRunnerSystem.Run(`git fetch --all --tags || true`);
if ((CloudRunner.buildParameters.branch || '').startsWith('pull/')) {
await CloudRunnerSystem.Run(`git fetch origin +refs/pull/*:refs/remotes/origin/pull/* || true`);
}
await CloudRunnerSystem.Run(`git fetch`);
await CloudRunnerSystem.Run(`git lfs pull`);
await CloudRunnerSystem.Run(`git lfs checkout || true`);
const sha = CloudRunner.buildParameters.gitSha;
const branch = CloudRunner.buildParameters.branch;
try {
await CloudRunnerSystem.Run(`git reset --hard "${sha}"`);
await CloudRunnerSystem.Run(`git checkout ${sha}`);
} catch {
RemoteClientLogger.logWarning(`Retained workspace: SHA not found, falling back to branch ${branch}`);
try {
await CloudRunnerSystem.Run(`git checkout ${branch}`);
} catch (error) {
if ((branch || '').startsWith('pull/')) {
await CloudRunnerSystem.Run(`git checkout origin/${branch}`);
} else {
throw error;
}
}
}
await CloudRunnerSystem.Run(`git reset --hard "${CloudRunner.buildParameters.gitSha}"`);
await CloudRunnerSystem.Run(`git checkout ${CloudRunner.buildParameters.gitSha}`);
return true;
}

View File

@@ -1,23 +1,8 @@
import CloudRunnerLogger from '../services/core/cloud-runner-logger';
import fs from 'node:fs';
import path from 'node:path';
import CloudRunner from '../cloud-runner';
import CloudRunnerOptions from '../options/cloud-runner-options';
export class RemoteClientLogger {
private static get LogFilePath() {
// Use a cross-platform temporary directory for local development
if (process.platform === 'win32') {
return path.join(process.cwd(), 'temp', 'job-log.txt');
}
return path.join(`/home`, `job-log.txt`);
}
public static log(message: string) {
const finalMessage = `[Client] ${message}`;
this.appendToFile(finalMessage);
CloudRunnerLogger.log(finalMessage);
CloudRunnerLogger.log(`[Client] ${message}`);
}
public static logCliError(message: string) {
@@ -31,98 +16,4 @@ export class RemoteClientLogger {
public static logWarning(message: string) {
CloudRunnerLogger.logWarning(message);
}
public static appendToFile(message: string) {
if (CloudRunner.isCloudRunnerEnvironment) {
// Ensure the directory exists before writing
const logDirectory = path.dirname(RemoteClientLogger.LogFilePath);
if (!fs.existsSync(logDirectory)) {
fs.mkdirSync(logDirectory, { recursive: true });
}
fs.appendFileSync(RemoteClientLogger.LogFilePath, `${message}\n`);
}
}
public static async handleLogManagementPostJob() {
if (CloudRunnerOptions.providerStrategy !== 'k8s') {
return;
}
const collectedLogsMessage = `Collected Logs`;
// Write to log file first so it's captured even if kubectl has issues
// This ensures the message is available in BuildResults when logs are read from the file
RemoteClientLogger.appendToFile(collectedLogsMessage);
// For K8s, write to stdout/stderr so kubectl logs can capture it
// This is critical because kubectl logs reads from stdout/stderr, not from GitHub Actions logs
// Write multiple times to increase chance of capture if kubectl is having issues
if (CloudRunnerOptions.providerStrategy === 'k8s') {
// Write to stdout multiple times to increase chance of capture
for (let index = 0; index < 3; index++) {
process.stdout.write(`${collectedLogsMessage}\n`, 'utf8');
process.stderr.write(`${collectedLogsMessage}\n`, 'utf8');
}
// Ensure stdout/stderr are flushed
if (!process.stdout.isTTY) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
// Also log via CloudRunnerLogger for GitHub Actions
CloudRunnerLogger.log(collectedLogsMessage);
// check for log file not existing
if (!fs.existsSync(RemoteClientLogger.LogFilePath)) {
const logFileMissingMessage = `Log file does not exist`;
if (CloudRunnerOptions.providerStrategy === 'k8s') {
process.stdout.write(`${logFileMissingMessage}\n`, 'utf8');
}
CloudRunnerLogger.log(logFileMissingMessage);
// check if CloudRunner.isCloudRunnerEnvironment is true, log
if (!CloudRunner.isCloudRunnerEnvironment) {
const notCloudEnvironmentMessage = `Cloud Runner is not running in a cloud environment, not collecting logs`;
if (CloudRunnerOptions.providerStrategy === 'k8s') {
process.stdout.write(`${notCloudEnvironmentMessage}\n`, 'utf8');
}
CloudRunnerLogger.log(notCloudEnvironmentMessage);
}
return;
}
const logFileExistsMessage = `Log file exist`;
if (CloudRunnerOptions.providerStrategy === 'k8s') {
process.stdout.write(`${logFileExistsMessage}\n`, 'utf8');
}
CloudRunnerLogger.log(logFileExistsMessage);
await new Promise((resolve) => setTimeout(resolve, 1));
// let hashedLogs = fs.readFileSync(RemoteClientLogger.LogFilePath).toString();
//
// hashedLogs = md5(hashedLogs);
//
// for (let index = 0; index < 3; index++) {
// CloudRunnerLogger.log(`LOGHASH: ${hashedLogs}`);
// const logs = fs.readFileSync(RemoteClientLogger.LogFilePath).toString();
// CloudRunnerLogger.log(`LOGS: ${Buffer.from(logs).toString('base64')}`);
// CloudRunnerLogger.log(
// `Game CI's "Cloud Runner System" will cancel the log when it has successfully received the log data to verify all logs have been received.`,
// );
//
// // wait for 15 seconds to allow the log to be sent
// await new Promise((resolve) => setTimeout(resolve, 15000));
// }
}
public static HandleLog(message: string): boolean {
if (RemoteClientLogger.value !== '') {
RemoteClientLogger.value += `\n`;
}
RemoteClientLogger.value += message;
return false;
}
static value: string = '';
}

View File

@@ -1,24 +0,0 @@
import BuildParameters from '../../../build-parameters';
class CloudRunnerResult {
public BuildParameters: BuildParameters;
public BuildResults: string;
public BuildSucceeded: boolean;
public BuildFinished: boolean;
public LibraryCacheUsed: boolean;
public constructor(
buildParameters: BuildParameters,
buildResults: string,
buildSucceeded: boolean,
buildFinished: boolean,
libraryCacheUsed: boolean,
) {
this.BuildParameters = buildParameters;
this.BuildResults = buildResults;
this.BuildSucceeded = buildSucceeded;
this.BuildFinished = buildFinished;
this.LibraryCacheUsed = libraryCacheUsed;
}
}
export default CloudRunnerResult;

View File

@@ -16,13 +16,7 @@ export class CloudRunnerSystem {
});
}
public static async Run(
command: string,
suppressError = false,
suppressLogs = false,
// eslint-disable-next-line no-unused-vars
outputCallback?: (output: string) => void,
) {
public static async Run(command: string, suppressError = false, suppressLogs = false) {
for (const element of command.split(`\n`)) {
if (!suppressLogs) {
RemoteClientLogger.log(element);
@@ -31,7 +25,7 @@ export class CloudRunnerSystem {
return await new Promise<string>((promise, throwError) => {
let output = '';
const child = exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout, stderr) => {
const child = exec(command, (error, stdout, stderr) => {
if (!suppressError && error) {
RemoteClientLogger.log(error.toString());
throwError(error);
@@ -44,9 +38,6 @@ export class CloudRunnerSystem {
output += diagnosticOutput;
}
const outputChunk = `${stdout}`;
if (outputCallback) {
outputCallback(outputChunk);
}
output += outputChunk;
});
child.on('close', (code) => {

View File

@@ -47,9 +47,9 @@ export class FollowLogStreamService {
} else if (message.toLowerCase().includes('cannot be found')) {
FollowLogStreamService.errors += `\n${message}`;
}
// Always append log lines to output so tests can assert on BuildResults
output += `${message}\n`;
if (CloudRunner.buildParameters.cloudRunnerDebug) {
output += `${message}\n`;
}
CloudRunnerLogger.log(`[${CloudRunnerStatics.logPrefix}] ${message}`);
return { shouldReadLogs, shouldCleanup, output };

View File

@@ -1,84 +0,0 @@
import CloudRunnerLogger from './cloud-runner-logger';
import CloudRunnerOptions from '../../options/cloud-runner-options';
import CloudRunner from '../../cloud-runner';
import { CloudRunnerSystem } from './cloud-runner-system';
class ResourceTracking {
static isEnabled(): boolean {
return (
CloudRunnerOptions.resourceTracking ||
CloudRunnerOptions.cloudRunnerDebug ||
process.env['cloudRunnerTests'] === 'true'
);
}
static logAllocationSummary(context: string) {
if (!ResourceTracking.isEnabled()) {
return;
}
const buildParameters = CloudRunner.buildParameters;
const allocations = {
providerStrategy: buildParameters.providerStrategy,
containerCpu: buildParameters.containerCpu,
containerMemory: buildParameters.containerMemory,
dockerCpuLimit: buildParameters.dockerCpuLimit,
dockerMemoryLimit: buildParameters.dockerMemoryLimit,
kubeVolumeSize: buildParameters.kubeVolumeSize,
kubeStorageClass: buildParameters.kubeStorageClass,
kubeVolume: buildParameters.kubeVolume,
containerNamespace: buildParameters.containerNamespace,
storageProvider: buildParameters.storageProvider,
rcloneRemote: buildParameters.rcloneRemote,
dockerWorkspacePath: buildParameters.dockerWorkspacePath,
cacheKey: buildParameters.cacheKey,
maxRetainedWorkspaces: buildParameters.maxRetainedWorkspaces,
useCompressionStrategy: buildParameters.useCompressionStrategy,
useLargePackages: buildParameters.useLargePackages,
ephemeralStorageRequest: process.env['cloudRunnerTests'] === 'true' ? 'not set' : '2Gi',
};
CloudRunnerLogger.log(`[ResourceTracking] Allocation summary (${context}):`);
CloudRunnerLogger.log(JSON.stringify(allocations, undefined, 2));
}
static async logDiskUsageSnapshot(context: string) {
if (!ResourceTracking.isEnabled()) {
return;
}
CloudRunnerLogger.log(`[ResourceTracking] Disk usage snapshot (${context})`);
await ResourceTracking.runAndLog('df -h', 'df -h');
await ResourceTracking.runAndLog('du -sh .', 'du -sh .');
await ResourceTracking.runAndLog('du -sh ./cloud-runner-cache', 'du -sh ./cloud-runner-cache');
await ResourceTracking.runAndLog('du -sh ./temp', 'du -sh ./temp');
await ResourceTracking.runAndLog('du -sh ./logs', 'du -sh ./logs');
}
static async logK3dNodeDiskUsage(context: string) {
if (!ResourceTracking.isEnabled()) {
return;
}
const nodes = ['k3d-unity-builder-agent-0', 'k3d-unity-builder-server-0'];
CloudRunnerLogger.log(`[ResourceTracking] K3d node disk usage (${context})`);
for (const node of nodes) {
await ResourceTracking.runAndLog(
`k3d node ${node}`,
`docker exec ${node} sh -c "df -h /var/lib/rancher/k3s 2>/dev/null || df -h / 2>/dev/null || true" || true`,
);
}
}
private static async runAndLog(label: string, command: string) {
try {
const output = await CloudRunnerSystem.Run(command, true, true);
const trimmed = output.trim();
CloudRunnerLogger.log(`[ResourceTracking] ${label}:\n${trimmed || 'no output'}`);
} catch (error: any) {
CloudRunnerLogger.log(`[ResourceTracking] ${label} failed: ${error?.message || error}`);
}
}
}
export default ResourceTracking;

View File

@@ -1,112 +1,23 @@
import { CloudRunnerSystem } from './cloud-runner-system';
import fs from 'node:fs';
import CloudRunnerLogger from './cloud-runner-logger';
import BuildParameters from '../../../build-parameters';
import CloudRunner from '../../cloud-runner';
import Input from '../../../input';
import {
CreateBucketCommand,
DeleteObjectCommand,
HeadBucketCommand,
ListObjectsV2Command,
PutObjectCommand,
S3,
} from '@aws-sdk/client-s3';
import { AwsClientFactory } from '../../providers/aws/aws-client-factory';
import { promisify } from 'node:util';
import { exec as execCallback } from 'node:child_process';
const exec = promisify(execCallback);
export class SharedWorkspaceLocking {
private static _s3: S3;
private static get s3(): S3 {
if (!SharedWorkspaceLocking._s3) {
// Use factory so LocalStack endpoint/path-style settings are honored
SharedWorkspaceLocking._s3 = AwsClientFactory.getS3();
}
return SharedWorkspaceLocking._s3;
}
private static get useRclone() {
return CloudRunner.buildParameters.storageProvider === 'rclone';
}
private static async rclone(command: string): Promise<string> {
const { stdout } = await exec(`rclone ${command}`);
return stdout.toString();
}
private static get bucket() {
return SharedWorkspaceLocking.useRclone
? CloudRunner.buildParameters.rcloneRemote
: CloudRunner.buildParameters.awsStackName;
}
public static get workspaceBucketRoot() {
return SharedWorkspaceLocking.useRclone
? `${SharedWorkspaceLocking.bucket}/`
: `s3://${SharedWorkspaceLocking.bucket}/`;
return `s3://${CloudRunner.buildParameters.awsStackName}/`;
}
public static get workspaceRoot() {
return `${SharedWorkspaceLocking.workspaceBucketRoot}locks/`;
}
private static get workspacePrefix() {
return `locks/`;
}
private static async ensureBucketExists(): Promise<void> {
const bucket = SharedWorkspaceLocking.bucket;
if (SharedWorkspaceLocking.useRclone) {
try {
await SharedWorkspaceLocking.rclone(`lsf ${bucket}`);
} catch {
await SharedWorkspaceLocking.rclone(`mkdir ${bucket}`);
}
return;
}
try {
await SharedWorkspaceLocking.s3.send(new HeadBucketCommand({ Bucket: bucket }));
} catch {
const region = Input.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
const createParameters: any = { Bucket: bucket };
if (region && region !== 'us-east-1') {
createParameters.CreateBucketConfiguration = { LocationConstraint: region };
}
await SharedWorkspaceLocking.s3.send(new CreateBucketCommand(createParameters));
}
}
private static async listObjects(prefix: string, bucket = SharedWorkspaceLocking.bucket): Promise<string[]> {
await SharedWorkspaceLocking.ensureBucketExists();
if (prefix !== '' && !prefix.endsWith('/')) {
prefix += '/';
}
if (SharedWorkspaceLocking.useRclone) {
const path = `${bucket}/${prefix}`;
try {
const output = await SharedWorkspaceLocking.rclone(`lsjson ${path}`);
const json = JSON.parse(output) as { Name: string; IsDir: boolean }[];
return json.map((entry) => (entry.IsDir ? `${entry.Name}/` : entry.Name));
} catch {
return [];
}
}
const result = await SharedWorkspaceLocking.s3.send(
new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, Delimiter: '/' }),
);
const entries: string[] = [];
for (const p of result.CommonPrefixes || []) {
if (p.Prefix) entries.push(p.Prefix.slice(prefix.length));
}
for (const c of result.Contents || []) {
if (c.Key && c.Key !== prefix) entries.push(c.Key.slice(prefix.length));
}
return entries;
}
public static async GetAllWorkspaces(buildParametersContext: BuildParameters): Promise<string[]> {
if (!(await SharedWorkspaceLocking.DoesCacheKeyTopLevelExist(buildParametersContext))) {
return [];
}
return (
await SharedWorkspaceLocking.listObjects(
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
await SharedWorkspaceLocking.ReadLines(
`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/`,
)
)
.map((x) => x.replace(`/`, ``))
@@ -115,11 +26,13 @@ export class SharedWorkspaceLocking {
}
public static async DoesCacheKeyTopLevelExist(buildParametersContext: BuildParameters) {
try {
const rootLines = await SharedWorkspaceLocking.listObjects('');
const rootLines = await SharedWorkspaceLocking.ReadLines(
`aws s3 ls ${SharedWorkspaceLocking.workspaceBucketRoot}`,
);
const lockFolderExists = rootLines.map((x) => x.replace(`/`, ``)).includes(`locks`);
if (lockFolderExists) {
const lines = await SharedWorkspaceLocking.listObjects(SharedWorkspaceLocking.workspacePrefix);
const lines = await SharedWorkspaceLocking.ReadLines(`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}`);
return lines.map((x) => x.replace(`/`, ``)).includes(buildParametersContext.cacheKey);
} else {
@@ -142,8 +55,8 @@ export class SharedWorkspaceLocking {
}
return (
await SharedWorkspaceLocking.listObjects(
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
await SharedWorkspaceLocking.ReadLines(
`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/`,
)
)
.map((x) => x.replace(`/`, ``))
@@ -269,8 +182,8 @@ export class SharedWorkspaceLocking {
}
return (
await SharedWorkspaceLocking.listObjects(
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
await SharedWorkspaceLocking.ReadLines(
`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/`,
)
)
.map((x) => x.replace(`/`, ``))
@@ -282,8 +195,8 @@ export class SharedWorkspaceLocking {
if (!(await SharedWorkspaceLocking.DoesWorkspaceExist(workspace, buildParametersContext))) {
throw new Error(`workspace doesn't exist ${workspace}`);
}
const files = await SharedWorkspaceLocking.listObjects(
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
const files = await SharedWorkspaceLocking.ReadLines(
`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/`,
);
const lockFilesExist =
@@ -299,13 +212,14 @@ export class SharedWorkspaceLocking {
throw new Error(`${workspace} already exists`);
}
const timestamp = Date.now();
const key = `${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/${timestamp}_${workspace}_workspace`;
await SharedWorkspaceLocking.ensureBucketExists();
await (SharedWorkspaceLocking.useRclone
? SharedWorkspaceLocking.rclone(`touch ${SharedWorkspaceLocking.bucket}/${key}`)
: SharedWorkspaceLocking.s3.send(
new PutObjectCommand({ Bucket: SharedWorkspaceLocking.bucket, Key: key, Body: new Uint8Array(0) }),
));
const file = `${timestamp}_${workspace}_workspace`;
fs.writeFileSync(file, '');
await CloudRunnerSystem.Run(
`aws s3 cp ./${file} ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/${file}`,
false,
true,
);
fs.rmSync(file);
const workspaces = await SharedWorkspaceLocking.GetAllWorkspaces(buildParametersContext);
@@ -327,24 +241,25 @@ export class SharedWorkspaceLocking {
): Promise<boolean> {
const existingWorkspace = workspace.endsWith(`_workspace`);
const ending = existingWorkspace ? workspace : `${workspace}_workspace`;
const key = `${SharedWorkspaceLocking.workspacePrefix}${
buildParametersContext.cacheKey
}/${Date.now()}_${runId}_${ending}_lock`;
await SharedWorkspaceLocking.ensureBucketExists();
await (SharedWorkspaceLocking.useRclone
? SharedWorkspaceLocking.rclone(`touch ${SharedWorkspaceLocking.bucket}/${key}`)
: SharedWorkspaceLocking.s3.send(
new PutObjectCommand({ Bucket: SharedWorkspaceLocking.bucket, Key: key, Body: new Uint8Array(0) }),
));
const file = `${Date.now()}_${runId}_${ending}_lock`;
fs.writeFileSync(file, '');
await CloudRunnerSystem.Run(
`aws s3 cp ./${file} ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/${file}`,
false,
true,
);
fs.rmSync(file);
const hasLock = await SharedWorkspaceLocking.HasWorkspaceLock(workspace, runId, buildParametersContext);
if (hasLock) {
CloudRunner.lockedWorkspace = workspace;
} else {
await (SharedWorkspaceLocking.useRclone
? SharedWorkspaceLocking.rclone(`delete ${SharedWorkspaceLocking.bucket}/${key}`)
: SharedWorkspaceLocking.s3.send(new DeleteObjectCommand({ Bucket: SharedWorkspaceLocking.bucket, Key: key })));
await CloudRunnerSystem.Run(
`aws s3 rm ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/${file}`,
false,
true,
);
}
return hasLock;
@@ -355,47 +270,30 @@ export class SharedWorkspaceLocking {
runId: string,
buildParametersContext: BuildParameters,
): Promise<boolean> {
await SharedWorkspaceLocking.ensureBucketExists();
const files = await SharedWorkspaceLocking.GetAllLocksForWorkspace(workspace, buildParametersContext);
const file = files.find((x) => x.includes(workspace) && x.endsWith(`_lock`) && x.includes(runId));
CloudRunnerLogger.log(`All Locks ${files} ${workspace} ${runId}`);
CloudRunnerLogger.log(`Deleting lock ${workspace}/${file}`);
CloudRunnerLogger.log(`rm ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/${file}`);
if (file) {
await (SharedWorkspaceLocking.useRclone
? SharedWorkspaceLocking.rclone(
`delete ${SharedWorkspaceLocking.bucket}/${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/${file}`,
)
: SharedWorkspaceLocking.s3.send(
new DeleteObjectCommand({
Bucket: SharedWorkspaceLocking.bucket,
Key: `${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/${file}`,
}),
));
}
await CloudRunnerSystem.Run(
`aws s3 rm ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/${file}`,
false,
true,
);
return !(await SharedWorkspaceLocking.HasWorkspaceLock(workspace, runId, buildParametersContext));
}
public static async CleanupWorkspace(workspace: string, buildParametersContext: BuildParameters) {
const prefix = `${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`;
const files = await SharedWorkspaceLocking.listObjects(prefix);
for (const file of files.filter((x) => x.includes(`_${workspace}_`))) {
await (SharedWorkspaceLocking.useRclone
? SharedWorkspaceLocking.rclone(`delete ${SharedWorkspaceLocking.bucket}/${prefix}${file}`)
: SharedWorkspaceLocking.s3.send(
new DeleteObjectCommand({ Bucket: SharedWorkspaceLocking.bucket, Key: `${prefix}${file}` }),
));
}
await CloudRunnerSystem.Run(
`aws s3 rm ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey} --exclude "*" --include "*_${workspace}_*"`,
false,
true,
);
}
public static async ReadLines(command: string): Promise<string[]> {
const path = command.replace('aws s3 ls', '').replace('rclone lsf', '').trim();
const withoutScheme = path.replace('s3://', '');
const [bucket, ...rest] = withoutScheme.split('/');
const prefix = rest.join('/');
return SharedWorkspaceLocking.listObjects(prefix, bucket);
return CloudRunnerSystem.RunAndReadLines(command);
}
}

View File

@@ -33,9 +33,6 @@ export class TaskParameterSerializer {
...TaskParameterSerializer.serializeInput(),
...TaskParameterSerializer.serializeCloudRunnerOptions(),
...CommandHookService.getSecrets(CommandHookService.getHooks(buildParameters.commandHooks)),
// Include AWS environment variables for LocalStack compatibility
...TaskParameterSerializer.serializeAwsEnvironmentVariables(),
]
.filter(
(x) =>
@@ -94,28 +91,6 @@ export class TaskParameterSerializer {
return TaskParameterSerializer.serializeFromType(CloudRunnerOptions);
}
private static serializeAwsEnvironmentVariables() {
const awsEnvironmentVariables = [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_DEFAULT_REGION',
'AWS_REGION',
'AWS_S3_ENDPOINT',
'AWS_ENDPOINT',
'AWS_CLOUD_FORMATION_ENDPOINT',
'AWS_ECS_ENDPOINT',
'AWS_KINESIS_ENDPOINT',
'AWS_CLOUD_WATCH_LOGS_ENDPOINT',
];
return awsEnvironmentVariables
.filter((key) => process.env[key] !== undefined)
.map((key) => ({
name: key,
value: process.env[key] || '',
}));
}
public static ToEnvVarFormat(input: string): string {
return CloudRunnerOptions.ToEnvVarFormat(input);
}
@@ -171,8 +146,7 @@ export class TaskParameterSerializer {
array = TaskParameterSerializer.tryAddInput(array, 'UNITY_SERIAL');
array = TaskParameterSerializer.tryAddInput(array, 'UNITY_EMAIL');
array = TaskParameterSerializer.tryAddInput(array, 'UNITY_PASSWORD');
// array = TaskParameterSerializer.tryAddInput(array, 'UNITY_LICENSE');
array = TaskParameterSerializer.tryAddInput(array, 'UNITY_LICENSE');
array = TaskParameterSerializer.tryAddInput(array, 'GIT_PRIVATE_TOKEN');
return array;

View File

@@ -1,5 +1,6 @@
import YAML from 'yaml';
import CloudRunner from '../../cloud-runner';
import * as core from '@actions/core';
import { CustomWorkflow } from '../../workflows/custom-workflow';
import { RemoteClientLogger } from '../../remote-client/remote-client-logger';
import path from 'node:path';
@@ -37,29 +38,17 @@ export class ContainerHookService {
image: amazon/aws-cli
hook: after
commands: |
if command -v aws > /dev/null 2>&1; then
if [ -n "$AWS_ACCESS_KEY_ID" ]; then
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile default || true
fi
if [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile default || true
fi
if [ -n "$AWS_DEFAULT_REGION" ]; then
aws configure set region "$AWS_DEFAULT_REGION" --profile default || true
fi
ENDPOINT_ARGS=""
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
aws $ENDPOINT_ARGS s3 cp /data/cache/$CACHE_KEY/build/build-${CloudRunner.buildParameters.buildGuid}.tar${
aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID --profile default
aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY --profile default
aws configure set region $AWS_DEFAULT_REGION --profile default
aws s3 cp /data/cache/$CACHE_KEY/build/build-${CloudRunner.buildParameters.buildGuid}.tar${
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
} s3://${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/$CACHE_KEY/build/build-$BUILD_GUID.tar${
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
} || true
rm /data/cache/$CACHE_KEY/build/build-${CloudRunner.buildParameters.buildGuid}.tar${
}
rm /data/cache/$CACHE_KEY/build/build-${CloudRunner.buildParameters.buildGuid}.tar${
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
} || true
else
echo "AWS CLI not available, skipping aws-s3-upload-build"
fi
}
secrets:
- name: awsAccessKeyId
value: ${process.env.AWS_ACCESS_KEY_ID || ``}
@@ -67,42 +56,27 @@ export class ContainerHookService {
value: ${process.env.AWS_SECRET_ACCESS_KEY || ``}
- name: awsDefaultRegion
value: ${process.env.AWS_REGION || ``}
- name: AWS_S3_ENDPOINT
value: ${CloudRunnerOptions.awsS3Endpoint || process.env.AWS_S3_ENDPOINT || ``}
- name: aws-s3-pull-build
image: amazon/aws-cli
commands: |
aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID --profile default
aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY --profile default
aws configure set region $AWS_DEFAULT_REGION --profile default
aws s3 ls ${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/ || true
aws s3 ls ${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/$CACHE_KEY/build || true
mkdir -p /data/cache/$CACHE_KEY/build/
if command -v aws > /dev/null 2>&1; then
if [ -n "$AWS_ACCESS_KEY_ID" ]; then
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile default || true
fi
if [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile default || true
fi
if [ -n "$AWS_DEFAULT_REGION" ]; then
aws configure set region "$AWS_DEFAULT_REGION" --profile default || true
fi
ENDPOINT_ARGS=""
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
aws $ENDPOINT_ARGS s3 ls ${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/ || true
aws $ENDPOINT_ARGS s3 ls ${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/$CACHE_KEY/build || true
aws s3 cp s3://${
CloudRunner.buildParameters.awsStackName
}/cloud-runner-cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
aws s3 cp s3://${
CloudRunner.buildParameters.awsStackName
}/cloud-runner-cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
} /data/cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
} || true
else
echo "AWS CLI not available, skipping aws-s3-pull-build"
fi
}
secrets:
- name: AWS_ACCESS_KEY_ID
- name: AWS_SECRET_ACCESS_KEY
- name: AWS_DEFAULT_REGION
- name: BUILD_GUID_TARGET
- name: AWS_S3_ENDPOINT
- name: steam-deploy-client
image: steamcmd/steamcmd
commands: |
@@ -143,29 +117,17 @@ export class ContainerHookService {
image: amazon/aws-cli
hook: after
commands: |
if command -v aws > /dev/null 2>&1; then
if [ -n "$AWS_ACCESS_KEY_ID" ]; then
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile default || true
fi
if [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile default || true
fi
if [ -n "$AWS_DEFAULT_REGION" ]; then
aws configure set region "$AWS_DEFAULT_REGION" --profile default || true
fi
ENDPOINT_ARGS=""
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
aws $ENDPOINT_ARGS s3 cp --recursive /data/cache/$CACHE_KEY/lfs s3://${
CloudRunner.buildParameters.awsStackName
}/cloud-runner-cache/$CACHE_KEY/lfs || true
rm -r /data/cache/$CACHE_KEY/lfs || true
aws $ENDPOINT_ARGS s3 cp --recursive /data/cache/$CACHE_KEY/Library s3://${
CloudRunner.buildParameters.awsStackName
}/cloud-runner-cache/$CACHE_KEY/Library || true
rm -r /data/cache/$CACHE_KEY/Library || true
else
echo "AWS CLI not available, skipping aws-s3-upload-cache"
fi
aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID --profile default
aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY --profile default
aws configure set region $AWS_DEFAULT_REGION --profile default
aws s3 cp --recursive /data/cache/$CACHE_KEY/lfs s3://${
CloudRunner.buildParameters.awsStackName
}/cloud-runner-cache/$CACHE_KEY/lfs
rm -r /data/cache/$CACHE_KEY/lfs
aws s3 cp --recursive /data/cache/$CACHE_KEY/Library s3://${
CloudRunner.buildParameters.awsStackName
}/cloud-runner-cache/$CACHE_KEY/Library
rm -r /data/cache/$CACHE_KEY/Library
secrets:
- name: AWS_ACCESS_KEY_ID
value: ${process.env.AWS_ACCESS_KEY_ID || ``}
@@ -173,160 +135,49 @@ export class ContainerHookService {
value: ${process.env.AWS_SECRET_ACCESS_KEY || ``}
- name: AWS_DEFAULT_REGION
value: ${process.env.AWS_REGION || ``}
- name: AWS_S3_ENDPOINT
value: ${CloudRunnerOptions.awsS3Endpoint || process.env.AWS_S3_ENDPOINT || ``}
- name: aws-s3-pull-cache
image: amazon/aws-cli
hook: before
commands: |
aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID --profile default
aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY --profile default
aws configure set region $AWS_DEFAULT_REGION --profile default
mkdir -p /data/cache/$CACHE_KEY/Library/
mkdir -p /data/cache/$CACHE_KEY/lfs/
if command -v aws > /dev/null 2>&1; then
if [ -n "$AWS_ACCESS_KEY_ID" ]; then
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile default || true
fi
if [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile default || true
fi
if [ -n "$AWS_DEFAULT_REGION" ]; then
aws configure set region "$AWS_DEFAULT_REGION" --profile default || true
fi
ENDPOINT_ARGS=""
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
aws $ENDPOINT_ARGS s3 ls ${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/ 2>/dev/null || true
aws $ENDPOINT_ARGS s3 ls ${
CloudRunner.buildParameters.awsStackName
}/cloud-runner-cache/$CACHE_KEY/ 2>/dev/null || true
BUCKET1="${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/$CACHE_KEY/Library/"
OBJECT1=""
LS_OUTPUT1="$(aws $ENDPOINT_ARGS s3 ls $BUCKET1 2>/dev/null || echo '')"
if [ -n "$LS_OUTPUT1" ] && [ "$LS_OUTPUT1" != "" ]; then
OBJECT1="$(echo "$LS_OUTPUT1" | sort | tail -n 1 | awk '{print $4}' || '')"
if [ -n "$OBJECT1" ] && [ "$OBJECT1" != "" ]; then
aws $ENDPOINT_ARGS s3 cp s3://$BUCKET1$OBJECT1 /data/cache/$CACHE_KEY/Library/ 2>/dev/null || true
fi
fi
BUCKET2="${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/$CACHE_KEY/lfs/"
OBJECT2=""
LS_OUTPUT2="$(aws $ENDPOINT_ARGS s3 ls $BUCKET2 2>/dev/null || echo '')"
if [ -n "$LS_OUTPUT2" ] && [ "$LS_OUTPUT2" != "" ]; then
OBJECT2="$(echo "$LS_OUTPUT2" | sort | tail -n 1 | awk '{print $4}' || '')"
if [ -n "$OBJECT2" ] && [ "$OBJECT2" != "" ]; then
aws $ENDPOINT_ARGS s3 cp s3://$BUCKET2$OBJECT2 /data/cache/$CACHE_KEY/lfs/ 2>/dev/null || true
fi
fi
else
echo "AWS CLI not available, skipping aws-s3-pull-cache"
fi
- name: rclone-upload-build
image: rclone/rclone
hook: after
commands: |
if command -v rclone > /dev/null 2>&1; then
rclone copy /data/cache/$CACHE_KEY/build/build-${CloudRunner.buildParameters.buildGuid}.tar${
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
} ${CloudRunner.buildParameters.rcloneRemote}/cloud-runner-cache/$CACHE_KEY/build/ || true
rm /data/cache/$CACHE_KEY/build/build-${CloudRunner.buildParameters.buildGuid}.tar${
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
} || true
else
echo "rclone not available, skipping rclone-upload-build"
fi
aws s3 ls ${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/ || true
aws s3 ls ${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/$CACHE_KEY/ || true
BUCKET1="${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/$CACHE_KEY/Library/"
aws s3 ls $BUCKET1 || true
OBJECT1="$(aws s3 ls $BUCKET1 | sort | tail -n 1 | awk '{print $4}' || '')"
aws s3 cp s3://$BUCKET1$OBJECT1 /data/cache/$CACHE_KEY/Library/ || true
BUCKET2="${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/$CACHE_KEY/lfs/"
aws s3 ls $BUCKET2 || true
OBJECT2="$(aws s3 ls $BUCKET2 | sort | tail -n 1 | awk '{print $4}' || '')"
aws s3 cp s3://$BUCKET2$OBJECT2 /data/cache/$CACHE_KEY/lfs/ || true
secrets:
- name: RCLONE_REMOTE
value: ${CloudRunner.buildParameters.rcloneRemote || ``}
- name: rclone-pull-build
image: rclone/rclone
commands: |
mkdir -p /data/cache/$CACHE_KEY/build/
if command -v rclone > /dev/null 2>&1; then
rclone copy ${
CloudRunner.buildParameters.rcloneRemote
}/cloud-runner-cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
} /data/cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
} || true
else
echo "rclone not available, skipping rclone-pull-build"
fi
secrets:
- name: BUILD_GUID_TARGET
- name: RCLONE_REMOTE
value: ${CloudRunner.buildParameters.rcloneRemote || ``}
- name: rclone-upload-cache
image: rclone/rclone
hook: after
commands: |
if command -v rclone > /dev/null 2>&1; then
rclone copy /data/cache/$CACHE_KEY/lfs ${
CloudRunner.buildParameters.rcloneRemote
}/cloud-runner-cache/$CACHE_KEY/lfs || true
rm -r /data/cache/$CACHE_KEY/lfs || true
rclone copy /data/cache/$CACHE_KEY/Library ${
CloudRunner.buildParameters.rcloneRemote
}/cloud-runner-cache/$CACHE_KEY/Library || true
rm -r /data/cache/$CACHE_KEY/Library || true
else
echo "rclone not available, skipping rclone-upload-cache"
fi
secrets:
- name: RCLONE_REMOTE
value: ${CloudRunner.buildParameters.rcloneRemote || ``}
- name: rclone-pull-cache
image: rclone/rclone
hook: before
commands: |
mkdir -p /data/cache/$CACHE_KEY/Library/
mkdir -p /data/cache/$CACHE_KEY/lfs/
if command -v rclone > /dev/null 2>&1; then
rclone copy ${
CloudRunner.buildParameters.rcloneRemote
}/cloud-runner-cache/$CACHE_KEY/Library /data/cache/$CACHE_KEY/Library/ || true
rclone copy ${
CloudRunner.buildParameters.rcloneRemote
}/cloud-runner-cache/$CACHE_KEY/lfs /data/cache/$CACHE_KEY/lfs/ || true
else
echo "rclone not available, skipping rclone-pull-cache"
fi
secrets:
- name: RCLONE_REMOTE
value: ${CloudRunner.buildParameters.rcloneRemote || ``}
- name: AWS_ACCESS_KEY_ID
value: ${process.env.AWS_ACCESS_KEY_ID || ``}
- name: AWS_SECRET_ACCESS_KEY
value: ${process.env.AWS_SECRET_ACCESS_KEY || ``}
- name: AWS_DEFAULT_REGION
value: ${process.env.AWS_REGION || ``}
- name: debug-cache
image: ubuntu
hook: after
commands: |
apt-get update > /dev/null || true
${CloudRunnerOptions.cloudRunnerDebug ? `apt-get install -y tree > /dev/null || true` : `#`}
${CloudRunnerOptions.cloudRunnerDebug ? `tree -L 3 /data/cache || true` : `#`}
apt-get update > /dev/null
${CloudRunnerOptions.cloudRunnerDebug ? `apt-get install -y tree > /dev/null` : `#`}
${CloudRunnerOptions.cloudRunnerDebug ? `tree -L 3 /data/cache` : `#`}
secrets:
- name: awsAccessKeyId
value: ${process.env.AWS_ACCESS_KEY_ID || ``}
- name: awsSecretAccessKey
value: ${process.env.AWS_SECRET_ACCESS_KEY || ``}
- name: awsDefaultRegion
value: ${process.env.AWS_REGION || ``}
- name: AWS_S3_ENDPOINT
value: ${CloudRunnerOptions.awsS3Endpoint || process.env.AWS_S3_ENDPOINT || ``}`,
value: ${process.env.AWS_REGION || ``}`,
).filter((x) => CloudRunnerOptions.containerHookFiles.includes(x.name) && x.hook === hookLifecycle);
// In local provider mode (non-container) or when AWS credentials are not present, skip AWS S3 hooks
const provider = CloudRunner.buildParameters?.providerStrategy;
const isContainerized = provider === 'aws' || provider === 'k8s' || provider === 'local-docker';
const hasAwsCreds =
(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
(process.env.awsAccessKeyId && process.env.awsSecretAccessKey);
// Always include AWS hooks on the AWS provider (task role provides creds),
// otherwise require explicit creds for other containerized providers.
const shouldIncludeAwsHooks =
isContainerized && !CloudRunner.buildParameters?.skipCache && (provider === 'aws' || Boolean(hasAwsCreds));
const filteredBuiltIns = shouldIncludeAwsHooks
? builtInContainerHooks
: builtInContainerHooks.filter((x) => x.image !== 'amazon/aws-cli');
if (filteredBuiltIns.length > 0) {
results.push(...filteredBuiltIns);
if (builtInContainerHooks.length > 0) {
results.push(...builtInContainerHooks);
}
return results;
@@ -370,11 +221,6 @@ export class ContainerHookService {
if (step.image === undefined) {
step.image = `ubuntu`;
}
// Ensure allowFailure defaults to false if not explicitly set
if (step.allowFailure === undefined) {
step.allowFailure = false;
}
}
if (object === undefined) {
throw new Error(`Failed to parse ${steps}`);
@@ -391,11 +237,13 @@ export class ContainerHookService {
];
if (steps.length > 0) {
if (!CloudRunner.buildParameters.isCliMode) core.startGroup('post build steps');
output += await CustomWorkflow.runContainerJob(
steps,
cloudRunnerStepState.environment,
cloudRunnerStepState.secrets,
);
if (!CloudRunner.buildParameters.isCliMode) core.endGroup();
}
return output;
@@ -408,11 +256,13 @@ export class ContainerHookService {
];
if (steps.length > 0) {
if (!CloudRunner.buildParameters.isCliMode) core.startGroup('pre build steps');
output += await CustomWorkflow.runContainerJob(
steps,
cloudRunnerStepState.environment,
cloudRunnerStepState.secrets,
);
if (!CloudRunner.buildParameters.isCliMode) core.endGroup();
}
return output;

View File

@@ -6,5 +6,4 @@ export class ContainerHook {
public name!: string;
public image: string = `ubuntu`;
public hook!: string;
public allowFailure: boolean = false; // If true, hook failures won't stop the build
}

View File

@@ -24,17 +24,11 @@ describe('Cloud Runner Async Workflows', () => {
unityVersion: UnityVersioning.read('test-project'),
asyncCloudRunner: `true`,
githubChecks: `true`,
providerStrategy: 'k8s',
buildPlatform: 'linux',
targetPlatform: 'StandaloneLinux64',
});
const baseImage = new ImageTag(buildParameter);
// Run the job
await CloudRunner.run(buildParameter, baseImage.toString());
// wait for 15 seconds
await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 12));
}, 1_000_000_000);
}
});

Some files were not shown because too many files have changed in this diff Show More