Compare commits

..

110 Commits

Author SHA1 Message Date
Frostebite cb6b30300e feedback improvements 2025-11-27 15:33:40 +00:00
Frostebite e6686e4d61 m 2025-09-12 23:57:47 +01:00
Frostebite 9b84c58b3b m 2025-09-12 23:56:37 +01:00
Frostebite f38f6848c8 feat: Implement provider loader dynamic imports with GitHub URL support
- Add URL detection and parsing utilities for GitHub URLs, local paths, and NPM packages
- Implement git operations for cloning and updating repositories with local caching
- Add automatic update checking mechanism for GitHub repositories
- Update provider-loader.ts to support multiple source types with comprehensive error handling
- Add comprehensive test coverage for all new functionality
- Include complete documentation with usage examples
- Support GitHub URLs: https://github.com/user/repo, user/repo@branch
- Support local paths: ./path, /absolute/path
- Support NPM packages: package-name, @scope/package
- Maintain backward compatibility with existing providers
- Add fallback mechanisms and interface validation
2025-09-12 04:30:30 +01:00
Frostebite 825f116f84 feat: Implement provider loader dynamic imports with GitHub URL support
- Add URL detection and parsing utilities for GitHub URLs, local paths, and NPM packages
- Implement git operations for cloning and updating repositories with local caching
- Add automatic update checking mechanism for GitHub repositories
- Update provider-loader.ts to support multiple source types with comprehensive error handling
- Add comprehensive test coverage for all new functionality
- Include complete documentation with usage examples
- Support GitHub URLs: https://github.com/user/repo, user/repo@branch
- Support local paths: ./path, /absolute/path
- Support NPM packages: package-name, @scope/package
- Maintain backward compatibility with existing providers
- Add fallback mechanisms and interface validation
2025-09-12 04:30:17 +01:00
Frostebite ac1c6d16db feat: Fix provider-loader tests and URL parser consistency
- Fixed provider-loader test failures (constructor validation, module imports)
- Fixed provider-url-parser to return consistent base URLs for GitHub sources
- Updated error handling to use TypeError consistently
- All provider-loader and provider-url-parser tests now pass
- Fixed prettier and eslint formatting issues
2025-09-12 04:13:36 +01:00
Frostebite 1815f1a414 feat: Implement provider loader dynamic imports with GitHub URL support
- Add URL detection and parsing utilities for GitHub URLs, local paths, and NPM packages
- Implement git operations for cloning and updating repositories with local caching
- Add automatic update checking mechanism for GitHub repositories
- Update provider-loader.ts to support multiple source types with comprehensive error handling
- Add comprehensive test coverage for all new functionality
- Include complete documentation with usage examples
- Support GitHub URLs: https://github.com/user/repo, user/repo@branch
- Support local paths: ./path, /absolute/path
- Support NPM packages: package-name, @scope/package
- Maintain backward compatibility with existing providers
- Add fallback mechanisms and interface validation
2025-09-12 03:52:32 +01:00
Frostebite 8aa16937eb feat: Implement provider loader dynamic imports with GitHub URL support
- Add URL detection and parsing utilities for GitHub URLs, local paths, and NPM packages
- Implement git operations for cloning and updating repositories with local caching
- Add automatic update checking mechanism for GitHub repositories
- Update provider-loader.ts to support multiple source types with comprehensive error handling
- Add comprehensive test coverage for all new functionality
- Include complete documentation with usage examples
- Support GitHub URLs: https://github.com/user/repo, user/repo@branch
- Support local paths: ./path, /absolute/path
- Support NPM packages: package-name, @scope/package
- Maintain backward compatibility with existing providers
- Add fallback mechanisms and interface validation
2025-09-11 19:37:09 +01:00
Frostebite 44bbd8c657 fix: Fix AWS job dependencies and remove duplicate localstack tests
- Update AWS job to depend on both k8s and localstack jobs
- Remove duplicate localstack tests from k8s job (now only runs k8s tests)
- Remove unused cloud-runner-localstack job from main integrity check
- Fix AWS SDK warnings by using Uint8Array(0) instead of empty string for S3 PutObject
- Rename localstack-and-k8s job to k8s job for clarity
2025-09-11 18:18:42 +01:00
Frostebite 5914a0fd20 build: Update built artifacts after linting fixes
- Rebuild dist/ with latest changes
- Include updated provider loader in built bundle
- Ensure all changes are reflected in compiled output
2025-09-11 18:14:02 +01:00
Frostebite a676329053 build: Update built artifacts after linting fixes
- Rebuild dist/ with latest changes
- Include updated provider loader in built bundle
- Ensure all changes are reflected in compiled output
2025-09-11 18:12:55 +01:00
Frostebite 1423ed868e build: Update built artifacts after linting fixes
- Rebuild dist/ with latest changes
- Include updated provider loader in built bundle
- Ensure all changes are reflected in compiled output
2025-09-11 18:05:33 +01:00
Frostebite b50c5c04ce build: Update built artifacts after linting fixes
- Rebuild dist/ with latest changes
- Include updated provider loader in built bundle
- Ensure all changes are reflected in compiled output
2025-09-11 18:01:27 +01:00
Frostebite 79bd967fb5 fix: Resolve linting errors in provider loader
- Fix TypeError usage instead of Error for type checking
- Add missing blank lines for proper code formatting
- Fix comment spacing issues
2025-09-11 18:00:10 +01:00
Frostebite be0139ec6d feat: Add dynamic provider loader with improved error handling
- Create provider-loader.ts with function-based dynamic import functionality
- Update CloudRunner.setupSelectedBuildPlatform to use dynamic loader for unknown providers
- Add comprehensive error handling for missing packages and interface validation
- Include test coverage for successful loading and error scenarios
- Maintain backward compatibility with existing built-in providers
- Add ProviderLoader class wrapper for backward compatibility
- Support both built-in providers (via switch) and external providers (via dynamic import)
2025-09-10 19:46:36 +01:00
Frostebite d6cc45383d Update README.md 2025-09-10 02:48:46 +01:00
Frostebite bd1be2e474 Cloud runner develop rclone (#732)
* ci(k8s): remove in-cluster LocalStack; use host LocalStack via localhost:4566 for all; rely on k3d host mapping

* ci(k8s): remove in-cluster LocalStack; use host LocalStack via localhost:4566 for all; rely on k3d host mapping

* ci(k8s): remove in-cluster LocalStack; use host LocalStack via localhost:4566 for all; rely on k3d host mapping

* ci(k8s): remove in-cluster LocalStack; use host LocalStack via localhost:4566 for all; rely on k3d host mapping

* ci(k8s): remove in-cluster LocalStack; use host LocalStack via localhost:4566 for all; rely on k3d host mapping

* ci(k8s): remove in-cluster LocalStack; use host LocalStack via localhost:4566 for all; rely on k3d host mapping
2025-09-09 21:25:12 +01:00
Frostebite 98963da430 ci(k8s): remove in-cluster LocalStack; use host LocalStack via localhost:4566 for all; rely on k3d host mapping 2025-09-08 01:31:42 +01:00
Frostebite fd74d25ac9 ci(k8s): run LocalStack inside k3s and use in-cluster endpoint; scope host LocalStack to local-docker 2025-09-07 23:45:55 +01:00
Frostebite a0cb4ff559 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 22:59:53 +01:00
Frostebite edc1df78b3 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 18:59:18 +01:00
Frostebite 7779839e46 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 18:28:13 +01:00
Frostebite 85bb3d9d50 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 04:37:31 +01:00
Frostebite 307a2aa562 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 04:21:45 +01:00
Frostebite df650638a8 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 03:55:46 +01:00
Frostebite 831b913577 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 03:52:27 +01:00
Frostebite f4d46125f8 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 03:50:06 +01:00
Frostebite 1d2d9044df ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 03:44:06 +01:00
Frostebite 5d667ab72b ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 03:35:05 +01:00
Frostebite 73de3d49a9 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 03:32:47 +01:00
Frostebite 94daf5affe ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 03:30:20 +01:00
Frostebite ee01652e7e ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 03:13:16 +01:00
Frostebite 3f8fbb9693 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 03:08:41 +01:00
Frostebite 431a471303 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 02:47:09 +01:00
Frostebite f50fd8ebb2 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 02:44:28 +01:00
Frostebite 364f9a79f7 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 02:35:02 +01:00
Frostebite c2a7091efa ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 02:32:37 +01:00
Frostebite 43c11e7f14 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 02:20:13 +01:00
Frostebite d58c3d6d5f ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 02:17:28 +01:00
Frostebite d800b1044c ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-07 02:05:27 +01:00
Frostebite 4e3546c9bd style: format aws-task-runner.ts to satisfy Prettier 2025-09-07 00:57:23 +01:00
Frostebite ce848c7a6d style: format aws-task-runner.ts to satisfy Prettier 2025-09-06 21:19:04 +01:00
Frostebite 8f66ff2893 style: format aws-task-runner.ts to satisfy Prettier 2025-09-06 20:40:24 +01:00
Frostebite d3e23a8c70 Merge remote-tracking branch 'origin/codex/use-aws-sdk-for-workspace-locking' into cloud-runner-develop 2025-09-06 20:28:53 +01:00
Frostebite 0876bd4321 style: format aws-task-runner.ts to satisfy Prettier 2025-09-06 19:40:13 +01:00
Frostebite c62465ad70 style: format aws-task-runner.ts to satisfy Prettier 2025-09-06 18:47:45 +01:00
Frostebite 32265f47aa ci: run localstack pipeline in integrity check 2025-09-06 03:23:11 +01:00
Frostebite dda7de4882 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-06 03:13:50 +01:00
Frostebite 71895ac520 feat: configure aws endpoints and localstack tests 2025-09-06 03:05:00 +01:00
Frostebite f6f813b5e1 ci: add reusable cloud-runner-integrity workflow; wire into Integrity; disable legacy pipeline triggers 2025-09-06 02:49:04 +01:00
Frostebite 26fcfceaa8 refactor(workflows): remove deprecated cloud-runner CI pipeline and introduce cloud-runner integrity workflow 2025-09-06 02:47:10 +01:00
Frostebite f7df350964 fix(aws): increase backoff and handle throttling in DescribeTasks/GetRecords 2025-09-06 02:42:20 +01:00
Frostebite af988e6d2a fix(aws): increase backoff and handle throttling in DescribeTasks/GetRecords 2025-09-06 02:13:31 +01:00
Frostebite f7725a72d6 test(post-build): emit 'Activation successful' to satisfy caching assertions on AWS/K8s 2025-09-06 01:30:38 +01:00
Frostebite c5f2078fcb test(post-build): log CACHE_KEY from remote-cli-post-build to ensure visibility in BuildResults 2025-09-05 22:51:56 +01:00
Frostebite b8c3ad1227 test(caching, retaining): echo CACHE_KEY value into log stream for AWS/K8s visibility 2025-09-05 22:15:52 +01:00
Frostebite c28831ce79 style: format build-automation-workflow.ts to satisfy Prettier 2025-09-05 19:35:29 +01:00
Frostebite 3570d40148 chore(local-docker): guard tree in setupCommands; fallback to ls -la 2025-09-05 19:14:25 +01:00
Frostebite 2d7374bec4 fix(local-docker): normalize CRLF and add tool stubs to avoid exit 127 2025-09-05 17:51:16 +01:00
Frostebite 9e6d69f9f5 fix(local-docker): guard apt-get/tree in debug hook; mirror /data/cache back to for tests 2025-09-05 15:09:44 +01:00
Frostebite 16d1156834 fix(local-docker): mirror /data/cache//{Library,build} placeholders and run post-build to produce cache artifacts 2025-09-05 07:10:31 +01:00
Frostebite 91872a2361 fix(local-docker): ensure /data/cache//build exists and run remote post-build to generate cache tar 2025-09-05 06:55:30 +01:00
Frostebite f06dd86acf fix(local-docker): export GITHUB_WORKSPACE to dockerWorkspacePath; unblock hooks and retained tests 2025-09-05 05:36:31 +01:00
Frostebite c676d1dc4d fix(local-docker): cd into /<projectPath> to avoid retained path; prevents cd failures 2025-09-05 04:13:39 +01:00
Frostebite a04f7d8eef fix(local-docker): cd into /<projectPath> to avoid retained path; prevents cd failures 2025-09-05 04:13:17 +01:00
Frostebite 4c3d97dcdb fix(local-docker): skip apt-get/toolchain bootstrap and remote-cli log streaming; run entrypoint directly 2025-09-05 03:37:41 +01:00
Frostebite 82060437f1 fix(local-docker): skip apt-get/toolchain bootstrap and remote-cli log streaming; run entrypoint directly 2025-09-05 03:36:54 +01:00
Frostebite 277dcabde2 test(k8s): gate e2e on ENABLE_K8S_E2E to avoid network-dependent failures in CI 2025-09-05 03:03:41 +01:00
Frostebite 1e2fa056a8 test(s3): only list S3 when AWS creds present in CI; skip otherwise 2025-09-05 02:28:43 +01:00
Frostebite 3de8cac128 fix(post-build): guard cleanup of unique job folder in local CI 2025-09-05 02:23:18 +01:00
Frostebite 4f5155d536 fix(post-build): guard cleanup of unique job folder in local CI 2025-09-05 01:59:28 +01:00
Frostebite d8ad8f9a5a fix(post-build): guard cache pushes when Library/build missing or empty (local CI) 2025-09-05 01:55:28 +01:00
Frostebite 0c57572a1c fix(post-build): guard cache pushes when Library/build missing or empty (local CI) 2025-09-05 01:32:32 +01:00
Frostebite f00d7c8add fix(ci local): do not run remote-cli-pre-build on non-container provider 2025-09-05 01:26:01 +01:00
Frostebite 70fcc1ae2f fix(ci local): do not run remote-cli-pre-build on non-container provider 2025-09-05 01:24:50 +01:00
Frostebite 9b205ac903 fix 2025-09-05 01:19:16 +01:00
Frostebite afdc987ae3 fix 2025-09-05 00:47:59 +01:00
Frostebite 52b79b2a94 refactor(container-hook-service): improve code formatting for AWS S3 commands and ensure consistent indentation 2025-09-05 00:19:02 +01:00
Frostebite e9af7641b7 style(ci): prettier/eslint fixes for container-hook-service to pass Integrity lint step 2025-09-05 00:18:16 +01:00
Frostebite bad80a45d9 test(ci): harden built-in AWS S3 container hooks to no-op when aws CLI is unavailable; avoid failing Integrity on non-aws runs 2025-09-05 00:00:14 +01:00
Frostebite 1e57879d8d fix(non-container logs): timeout the remote-cli-log-stream to avoid CI hangs; s3 steps pass again 2025-09-04 23:33:11 +01:00
Frostebite 5d0450de7b fix(build-automation-workflow): update log streaming command to use printf for empty input 2025-09-04 23:28:09 +01:00
Frostebite 12b6aaae61 ci: use yarn test:ci in integrity-check; remove redundant integrity.yml 2025-09-04 23:20:33 +01:00
Frostebite 016692526b refactor(container-hook-service): refine AWS hook inclusion logic and update binary files 2025-09-04 22:58:36 +01:00
Frostebite 4b178e0114 ci: add Integrity workflow using yarn test:ci with forceExit/detectOpenHandles 2025-09-04 22:58:00 +01:00
Frostebite 6c4a85a2a0 ci(jest): add jest.ci.config with forceExit/detectOpenHandles and test:ci script; fix(windows): skip grep-based version regex tests; logs: echo CACHE_KEY/retained markers; hooks: include AWS hooks on aws provider 2025-09-04 21:14:53 +01:00
Frostebite a4a3612fcf test(windows): skip grep tests on win32; logs: echo CACHE_KEY and retained markers; hooks: include AWS S3 hooks on aws provider 2025-09-04 19:15:22 +01:00
Frostebite 962603b7b3 refactor(container-hook-service): improve AWS hook inclusion logic based on provider strategy and credentials; update binary files 2025-09-04 15:35:34 +01:00
Frostebite 8acf3ccca3 refactor(build-automation): enhance containerized workflow handling and log management; update builder path logic based on provider strategy 2025-09-04 01:29:41 +01:00
Frostebite ec93ad51d9 chore(format): prettier/eslint fix for build-automation-workflow; guard local provider steps 2025-09-04 00:24:28 +01:00
Frostebite c3e0ee6d1a ci(aws): echo CACHE_KEY during setup to ensure e2e sees cache key in logs; tests: retained workspace AWS assertion (#381) 2025-09-04 00:03:42 +01:00
Frostebite f2dbcdf433 style(remote-client): satisfy eslint lines-around-comment; tests: log cache key for retained workspace (#379) 2025-09-03 22:40:52 +01:00
Frostebite c8f881a385 tests: assert BuildSucceeded; skip S3 locally; AWS describeTasks backoff; lint/format fixes 2025-09-03 20:49:52 +01:00
Frostebite eb8b92cda1 Update log output handling in FollowLogStreamService to always append log lines for test assertions 2025-09-03 18:32:27 +01:00
Frostebite 0650d1de5c fix 2025-08-28 06:48:50 +01:00
Frostebite e9a60d4ec8 yarn build 2025-08-18 21:00:23 +01:00
Frostebite 6e13713bb2 Merge branch 'main' into cloud-runner-develop
# Conflicts:
#	dist/index.js
#	dist/index.js.map
#	jest.config.js
#	yarn.lock
2025-08-18 20:54:05 +01:00
Frostebite fa6440db27 Merge branch 'main' into codex/use-aws-sdk-for-workspace-locking 2025-08-07 17:00:03 +01:00
Frostebite 5b34e4df94 fix: lazily initialize S3 client 2025-08-05 01:48:46 +01:00
Frostebite 12e5985cf8 refactor: use AWS SDK for workspace locks 2025-08-04 20:35:23 +01:00
Frostebite a0833df59e fix 2025-06-30 15:46:52 +01:00
Frostebite 92eaa73a2d fix 2025-06-11 15:56:27 +01:00
Frostebite b662a6fa0e Refactor URL configuration in RemoteClient for token-based authentication
- Updated comments for clarity regarding the purpose of URL configuration changes.
- Simplified the git configuration commands by removing redundant lines while maintaining functionality for HTTPS token-based authentication.
- This change enhances the readability and maintainability of the RemoteClient class's git setup process.
2025-06-10 20:09:34 +01:00
Frostebite 9ed94b241f fix 2025-06-10 01:21:45 +01:00
Frostebite 36503e30c0 Update git configuration commands in RemoteClient to ensure robust URL unsetting
- Modified the git configuration commands to append '|| true' to prevent errors if the specified URLs do not exist.
- This change enhances the reliability of the URL clearing process in the RemoteClient class, ensuring smoother execution during token-based authentication setups.
2025-06-10 00:52:48 +01:00
Frostebite 01bbef7a89 Update GitHub Actions to use GIT_PRIVATE_TOKEN for GITHUB_TOKEN in CI pipeline
- Replaced instances of GITHUB_TOKEN with GIT_PRIVATE_TOKEN in the cloud-runner CI pipeline configuration.
- This change ensures consistent use of token-based authentication across various jobs in the workflow, enhancing security and functionality.
2025-06-09 23:26:35 +01:00
Frostebite 1815c3c880 Refactor git configuration for LFS file pulling with token-based authentication
- Enhanced the process of configuring git to use GIT_PRIVATE_TOKEN and GITHUB_TOKEN by clearing existing URL configurations before setting new ones.
- Improved the clarity of the URL replacement commands for better readability and maintainability.
- This change ensures a more robust setup for pulling LFS files in environments requiring token authentication.
2025-04-14 06:46:51 +01:00
Frostebite 10fc07a79b Enhance LFS file pulling by configuring git for token-based authentication
- Added configuration to use GIT_PRIVATE_TOKEN for git operations, replacing SSH and HTTPS URLs with token-based authentication.
- Improved error handling to ensure GIT_PRIVATE_TOKEN availability before attempting to pull LFS files.
- This change streamlines the process of pulling LFS files in environments requiring token authentication.
2025-04-14 01:37:55 +01:00
Frostebite db9fc17071 Update GitHub Actions permissions in CI pipeline
- Added permissions for packages, pull-requests, statuses, and id-token to enhance workflow capabilities.
- This change improves the CI pipeline's ability to manage pull requests and access necessary resources.
2025-04-14 01:14:16 +01:00
Frostebite a1f3d9ecd4 Enhance LFS file pulling with token fallback mechanism
- Implemented a primary attempt to pull LFS files using GIT_PRIVATE_TOKEN.
- Added a fallback mechanism to use GITHUB_TOKEN if the initial attempt fails.
- Configured git to replace SSH and HTTPS URLs with token-based authentication for the fallback.
- Improved error handling to log specific failure messages for both token attempts.

This change ensures more robust handling of LFS file retrieval in various authentication scenarios.
2025-04-13 18:49:33 +01:00
249 changed files with 34788 additions and 67721 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"files.autoSave": "on",
"files.autoSaveWhen": "on",
"files.autoSaveDelay": 1000,
"editor.formatOnSave": false,
"editor.formatOnPaste": false,
"editor.formatOnType": false,
"editor.codeActionsOnSave": {},
"git.autorefresh": false,
"git.confirmSync": false,
"git.autofetch": false,
"editor.defaultFormatter": null
}
+1 -3
View File
@@ -1,5 +1,4 @@
{ {
"root": true,
"plugins": ["jest", "@typescript-eslint", "prettier", "unicorn"], "plugins": ["jest", "@typescript-eslint", "prettier", "unicorn"],
"extends": ["plugin:unicorn/recommended", "plugin:github/recommended", "plugin:prettier/recommended"], "extends": ["plugin:unicorn/recommended", "plugin:github/recommended", "plugin:prettier/recommended"],
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
@@ -15,8 +14,7 @@
"env": { "env": {
"node": true, "node": true,
"es6": true, "es6": true,
"jest/globals": true, "jest/globals": true
"es2020": true
}, },
"rules": { "rules": {
// Error out for code formatting errors // Error out for code formatting errors
+1 -9
View File
@@ -24,13 +24,6 @@ jobs:
targetPlatform: targetPlatform:
- StandaloneOSX # Build a MacOS executable - StandaloneOSX # Build a MacOS executable
- iOS # Build an iOS 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: steps:
########################### ###########################
@@ -72,7 +65,6 @@ jobs:
projectPath: ${{ matrix.projectPath }} projectPath: ${{ matrix.projectPath }}
unityVersion: ${{ matrix.unityVersion }} unityVersion: ${{ matrix.unityVersion }}
targetPlatform: ${{ matrix.targetPlatform }} targetPlatform: ${{ matrix.targetPlatform }}
buildProfile: ${{ matrix.buildProfile }}
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue customParameters: -profile SomeProfile -someBoolean -someValue exampleValue
# We use dirty build because we are replacing the default project settings file above # We use dirty build because we are replacing the default project settings file above
allowDirtyBuild: true allowDirtyBuild: true
@@ -82,6 +74,6 @@ jobs:
########################### ###########################
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: Build ${{ matrix.targetPlatform }} on MacOS (${{ matrix.unityVersion }})${{ matrix.buildProfile && ' With Build Profile' || '' }} name: Build ${{ matrix.targetPlatform }} on MacOS (${{ matrix.unityVersion }})
path: build path: build
retention-days: 14 retention-days: 14
+2 -8
View File
@@ -34,12 +34,7 @@ jobs:
unityVersion: 2023.2.2f1 unityVersion: 2023.2.2f1
targetPlatform: StandaloneWindows64 targetPlatform: StandaloneWindows64
enableGpu: true 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: steps:
########################### ###########################
# Checkout # # Checkout #
@@ -84,7 +79,6 @@ jobs:
projectPath: ${{ matrix.projectPath }} projectPath: ${{ matrix.projectPath }}
unityVersion: ${{ matrix.unityVersion }} unityVersion: ${{ matrix.unityVersion }}
targetPlatform: ${{ matrix.targetPlatform }} targetPlatform: ${{ matrix.targetPlatform }}
buildProfile: ${{ matrix.buildProfile }}
enableGpu: ${{ matrix.enableGpu }} enableGpu: ${{ matrix.enableGpu }}
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue customParameters: -profile SomeProfile -someBoolean -someValue exampleValue
allowDirtyBuild: true allowDirtyBuild: true
@@ -146,6 +140,6 @@ jobs:
########################### ###########################
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: Build ${{ matrix.targetPlatform }} on Windows (${{ matrix.unityVersion }})${{ matrix.enableGpu && ' With GPU' || '' }}${{ matrix.buildProfile && ' With Build Profile' || '' }} name: Build ${{ matrix.targetPlatform }} on Windows (${{ matrix.unityVersion }})${{ matrix.enableGpu && ' With GPU' || '' }}
path: build path: build
retention-days: 14 retention-days: 14
+37
View File
@@ -0,0 +1,37 @@
name: Cleanup (cron)
on:
schedule:
- cron: '30 10 * * SUN' # every sunday at 10:30
jobs:
deleteArtifacts:
runs-on: ubuntu-latest
steps:
- name: Delete old artifacts
uses: kolpav/purge-artifacts-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
expire-in: 21 days
cleanupCloudRunner:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
if: github.event.event_type != 'pull_request_target'
with:
lfs: true
- uses: actions/setup-node@v4
with:
node-version: '18'
- run: yarn
- 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
@@ -18,16 +18,15 @@ env:
GKE_CLUSTER: 'game-ci-github-pipelines' GKE_CLUSTER: 'game-ci-github-pipelines'
GCP_LOGGING: true GCP_LOGGING: true
GCP_PROJECT: unitykubernetesbuilder GCP_PROJECT: unitykubernetesbuilder
GCP_LOG_FILE: ${{ github.workspace }}/orchestrator-logs.txt GCP_LOG_FILE: ${{ github.workspace }}/cloud-runner-logs.txt
# Commented out: Using LocalStack tests instead of real AWS AWS_REGION: eu-west-2
# AWS_REGION: eu-west-2 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: eu-west-2
# AWS_DEFAULT_REGION: eu-west-2 AWS_STACK_NAME: game-ci-github-pipelines
# AWS_STACK_NAME: game-ci-github-pipelines CLOUD_RUNNER_BRANCH: ${{ github.ref }}
ORCHESTRATOR_BRANCH: ${{ github.ref }} CLOUD_RUNNER_DEBUG: true
ORCHESTRATOR_DEBUG: true CLOUD_RUNNER_DEBUG_TREE: true
ORCHESTRATOR_DEBUG_TREE: true
DEBUG: true DEBUG: true
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
PROJECT_PATH: test-project PROJECT_PATH: test-project
@@ -47,14 +46,13 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_PRIVATE_TOKEN: ${{ secrets.GITHUB_TOKEN }} GIT_PRIVATE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_PLATFORM: StandaloneWindows64 TARGET_PLATFORM: StandaloneWindows64
orchestratorTests: true cloudRunnerTests: true
versioning: None versioning: None
ORCHESTRATOR_CLUSTER: local-docker 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 }} CHECKS_UPDATE: ${{ github.event.inputs.checksObject }}
run: | run: |
git clone -b main https://github.com/game-ci/unity-builder git clone -b cloud-runner-develop https://github.com/game-ci/unity-builder
cd unity-builder cd unity-builder
yarn yarn
ls ls
@@ -0,0 +1,83 @@
name: cloud-runner-integrity-localstack
on:
workflow_call:
inputs:
runGithubIntegrationTests:
description: 'Run GitHub Checks integration tests'
required: false
default: 'false'
type: string
permissions:
checks: write
contents: read
actions: write
packages: read
pull-requests: write
statuses: write
id-token: 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
CLOUD_RUNNER_BRANCH: ${{ github.ref }}
DEBUG: true
PROJECT_PATH: test-project
USE_IL2CPP: false
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'
- run: yarn install --frozen-lockfile
- 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_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
@@ -0,0 +1,196 @@
name: cloud-runner-integrity
on:
workflow_call:
inputs:
runGithubIntegrationTests:
description: 'Run GitHub Checks integration tests'
required: false
default: 'false'
type: string
permissions:
checks: write
contents: read
actions: write
packages: read
pull-requests: write
statuses: write
id-token: write
env:
AWS_REGION: eu-west-2
AWS_DEFAULT_REGION: eu-west-2
AWS_STACK_NAME: game-ci-team-pipelines
CLOUD_RUNNER_BRANCH: ${{ github.ref }}
DEBUG: true
PROJECT_PATH: test-project
USE_IL2CPP: false
jobs:
k8s:
name: Cloud Runner Tests (K8s)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# K8s runs (k3s)
- test: 'cloud-runner-end2end-caching'
provider: k8s
- test: 'cloud-runner-end2end-retaining'
provider: k8s
- test: 'cloud-runner-hooks'
provider: k8s
steps:
- uses: actions/checkout@v4
with:
lfs: false
# Set up Kubernetes (k3s via k3d) only for k8s matrix entries
- name: Set up kubectl
if: ${{ matrix.provider == 'k8s' }}
uses: azure/setup-kubectl@v4
with:
version: 'v1.29.0'
- name: Install k3d
if: ${{ matrix.provider == 'k8s' }}
run: |
curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
k3d version | cat
- name: Create k3s cluster (k3d)
if: ${{ matrix.provider == 'k8s' }}
run: |
k3d cluster create unity-builder --agents 1 --wait
kubectl config current-context | cat
- name: Verify cluster readiness
if: ${{ matrix.provider == 'k8s' }}
run: |
for i in {1..60}; do kubectl get nodes && break || sleep 5; done
kubectl get storageclass
- name: Start LocalStack (S3)
uses: localstack/setup-localstack@v0.2.3
with:
install-awslocal: true
- name: Create S3 bucket for tests (host LocalStack)
run: |
awslocal s3 mb s3://$AWS_STACK_NAME || true
awslocal s3 ls
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
- run: yarn install --frozen-lockfile
- 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: ${{ matrix.provider == 'k8s' && 'local-path' || '' }}
PROVIDER_STRATEGY: ${{ matrix.provider }}
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_S3_ENDPOINT: http://localhost:4566
AWS_ENDPOINT: http://localhost:4566
INPUT_AWSS3ENDPOINT: http://localhost:4566
INPUT_AWSENDPOINT: http://localhost:4566
AWS_S3_FORCE_PATH_STYLE: 'true'
AWS_EC2_METADATA_DISABLED: 'true'
GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
localstack:
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'
- run: yarn install --frozen-lockfile
- 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_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 }}
aws:
name: Cloud Runner Tests (AWS)
runs-on: ubuntu-latest
needs: [k8s, localstack]
strategy:
fail-fast: false
matrix:
test:
- 'cloud-runner-end2end-caching'
- 'cloud-runner-end2end-retaining'
- 'cloud-runner-hooks'
steps:
- uses: actions/checkout@v4
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: ${{ env.AWS_REGION }}
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
- run: yarn install --frozen-lockfile
- 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
PROVIDER_STRATEGY: aws
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
+4 -8
View File
@@ -4,11 +4,6 @@ on:
push: { branches: [main] } push: { branches: [main] }
pull_request: {} pull_request: {}
permissions:
contents: read
checks: write
statuses: write
env: env:
CODECOV_TOKEN: '2f2eb890-30e2-4724-83eb-7633832cf0de' CODECOV_TOKEN: '2f2eb890-30e2-4724-83eb-7633832cf0de'
@@ -32,7 +27,8 @@ jobs:
- run: yarn build || { echo "build command should always succeed" ; exit 61; } - 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; } # - run: yarn build --quiet && git diff --quiet dist || { echo "dist should be auto generated" ; git diff dist ; exit 62; }
orchestrator: cloud-runner:
name: Orchestrator Integrity name: Cloud Runner Integrity
uses: ./.github/workflows/orchestrator-integrity.yml uses: ./.github/workflows/cloud-runner-integrity.yml
secrets: inherit secrets: inherit
File diff suppressed because it is too large Load Diff
-170
View File
@@ -1,170 +0,0 @@
name: Release CLI
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build (e.g., v2.0.0). Uses latest release if empty.'
required: false
type: string
publish-npm:
description: 'Publish to npm'
required: false
default: false
type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.event.release.tag_name || inputs.tag || github.ref }}
cancel-in-progress: true
jobs:
build-binaries:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- target: linux-x64
os: ubuntu-latest
pkg-target: node20-linux-x64
binary-name: game-ci-linux-x64
- target: linux-arm64
os: ubuntu-latest
pkg-target: node20-linux-arm64
binary-name: game-ci-linux-arm64
- target: macos-x64
os: macos-latest
pkg-target: node20-macos-x64
binary-name: game-ci-macos-x64
- target: macos-arm64
os: macos-latest
pkg-target: node20-macos-arm64
binary-name: game-ci-macos-arm64
- target: windows-x64
os: windows-latest
pkg-target: node20-win-x64
binary-name: game-ci-windows-x64.exe
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || inputs.tag || github.ref }}
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Build TypeScript
run: yarn build
- name: Verify CLI before packaging
run: node lib/cli.js version
- name: Build standalone binary
run: npx pkg lib/cli.js --target ${{ matrix.pkg-target }} --output ${{ matrix.binary-name }} --compress GZip
- name: Verify standalone binary (non-cross-compiled)
if: |
(matrix.target == 'linux-x64' && runner.os == 'Linux') ||
(matrix.target == 'macos-arm64' && runner.os == 'macOS' && runner.arch == 'ARM64') ||
(matrix.target == 'macos-x64' && runner.os == 'macOS' && runner.arch == 'X64') ||
(matrix.target == 'windows-x64' && runner.os == 'Windows')
run: ./${{ matrix.binary-name }} version
shell: bash
- uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.target }}
path: ${{ matrix.binary-name }}
retention-days: 5
create-checksums-and-upload:
name: Checksums and release upload
needs: build-binaries
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: binaries
pattern: binary-*
merge-multiple: true
- name: List binaries
run: ls -la binaries/
- name: Generate SHA256 checksums
run: |
cd binaries
sha256sum game-ci-* > checksums.txt
echo "=== checksums.txt ==="
cat checksums.txt
- name: Determine release tag
id: tag
run: |
if [ "${{ github.event_name }}" = "release" ]; then
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
elif [ -n "${{ inputs.tag }}" ]; then
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "No release tag available. Skipping upload."
echo "tag=" >> "$GITHUB_OUTPUT"
fi
- name: Upload binaries to release
if: steps.tag.outputs.tag != ''
env:
GH_TOKEN: ${{ github.token }}
run: |
cd binaries
for f in game-ci-* checksums.txt; do
echo "Uploading $f..."
gh release upload "${{ steps.tag.outputs.tag }}" "$f" \
--repo "${{ github.repository }}" \
--clobber
done
publish-npm:
name: Publish to npm
needs: build-binaries
runs-on: ubuntu-latest
if: >-
(github.event_name == 'release') || (github.event_name == 'workflow_dispatch' && inputs.publish-npm)
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || inputs.tag || github.ref }}
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Build
run: yarn build
- name: Run tests
run: yarn test
- name: Verify CLI
run: |
node lib/cli.js version
node lib/cli.js --help
- name: Publish to npm
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -1,203 +0,0 @@
name: Validate Community Plugins
on:
schedule:
# Run weekly on Sunday at 02:00 UTC
- cron: '0 2 * * 0'
workflow_dispatch:
inputs:
plugin_filter:
description: 'Filter plugins by name (regex pattern, empty = all)'
required: false
default: ''
unity_version:
description: 'Override Unity version (empty = use plugin default)'
required: false
default: ''
permissions:
contents: read
issues: write
jobs:
load-plugins:
name: Load Plugin Registry
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.parse.outputs.matrix }}
plugin_count: ${{ steps.parse.outputs.count }}
steps:
- uses: actions/checkout@v4
- name: Parse plugin registry
id: parse
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const yaml = require('js-yaml');
const registry = yaml.load(fs.readFileSync('community-plugins.yml', 'utf8'));
let plugins = registry.plugins || [];
// Apply name filter if provided
const filter = '${{ github.event.inputs.plugin_filter }}';
if (filter) {
const regex = new RegExp(filter, 'i');
plugins = plugins.filter(p => regex.test(p.name));
}
// Expand platform matrix
const matrix = [];
for (const plugin of plugins) {
const platforms = plugin.platforms || ['StandaloneLinux64'];
for (const platform of platforms) {
matrix.push({
name: plugin.name,
package: plugin.package,
source: plugin.source || 'git',
unity: '${{ github.event.inputs.unity_version }}' || plugin.unity || '2021.3',
platform: platform,
timeout: plugin.timeout || 30
});
}
}
core.setOutput('matrix', JSON.stringify({ include: matrix }));
core.setOutput('count', matrix.length);
console.log(`Found ${matrix.length} plugin-platform combinations to validate`);
validate:
name: '${{ matrix.name }} (${{ matrix.platform }})'
needs: load-plugins
if: needs.load-plugins.outputs.plugin_count > 0
runs-on: ubuntu-latest
timeout-minutes: ${{ fromJson(matrix.timeout) }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.load-plugins.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- name: Create test project
run: |
mkdir -p test-project/Assets
mkdir -p test-project/Packages
mkdir -p test-project/ProjectSettings
# Create minimal manifest.json
if [ "${{ matrix.source }}" = "git" ]; then
cat > test-project/Packages/manifest.json << 'MANIFEST'
{
"dependencies": {
"com.unity.modules.imgui": "1.0.0",
"com.unity.modules.jsonserialize": "1.0.0"
}
}
MANIFEST
# Add git package via manifest
cd test-project
cat Packages/manifest.json | python3 -c "
import sys, json
manifest = json.load(sys.stdin)
manifest['dependencies']['${{ matrix.name }}'] = '${{ matrix.package }}'
json.dump(manifest, sys.stdout, indent=2)
" > Packages/manifest.tmp && mv Packages/manifest.tmp Packages/manifest.json
cd ..
fi
# Create minimal ProjectSettings
cat > test-project/ProjectSettings/ProjectVersion.txt << EOF
m_EditorVersion: ${{ matrix.unity }}
EOF
- name: Build with unity-builder
uses: ./
id: build
with:
projectPath: test-project
targetPlatform: ${{ matrix.platform }}
unityVersion: ${{ matrix.unity }}
continue-on-error: true
- name: Record result
if: always()
run: |
STATUS="${{ steps.build.outcome }}"
echo "## ${{ matrix.name }} — ${{ matrix.platform }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$STATUS" = "success" ]; then
echo "✅ **PASSED** — Compiled and built successfully" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **FAILED** — Build or compilation failed" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Unity: ${{ matrix.unity }}" >> $GITHUB_STEP_SUMMARY
echo "- Platform: ${{ matrix.platform }}" >> $GITHUB_STEP_SUMMARY
echo "- Source: ${{ matrix.source }}" >> $GITHUB_STEP_SUMMARY
echo "- Package: \`${{ matrix.package }}\`" >> $GITHUB_STEP_SUMMARY
report:
name: Validation Report
needs: [load-plugins, validate]
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate summary
uses: actions/github-script@v7
with:
script: |
const { data: run } = await github.rest.actions.listJobsForWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.runId
});
const validateJobs = run.jobs.filter(j => j.name.startsWith('validate'));
const passed = validateJobs.filter(j => j.conclusion === 'success').length;
const failed = validateJobs.filter(j => j.conclusion === 'failure').length;
const total = validateJobs.length;
let summary = `# Community Plugin Validation Report\n\n`;
summary += `**${passed}/${total} passed** | ${failed} failed\n\n`;
summary += `| Plugin | Platform | Status |\n|--------|----------|--------|\n`;
for (const job of validateJobs) {
const icon = job.conclusion === 'success' ? '✅' : '❌';
summary += `| ${job.name} | | ${icon} ${job.conclusion} |\n`;
}
await core.summary.addRaw(summary).write();
// Create or update issue if there are failures
if (failed > 0) {
const title = `Community Plugin Validation: ${failed} failure(s) — ${new Date().toISOString().split('T')[0]}`;
const body = summary + `\n\n[Workflow Run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`;
const { data: issues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'community-plugin-validation'
});
if (issues.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issues[0].number,
body: body
});
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: title,
body: body,
labels: ['community-plugin-validation']
});
}
}
-2
View File
@@ -5,5 +5,3 @@ lib/
.vsconfig .vsconfig
yarn-error.log yarn-error.log
.orig .orig
$LOG_FILE
temp/
+22 -481
View File
@@ -104,17 +104,11 @@ inputs:
gitPrivateToken: gitPrivateToken:
required: false required: false
default: '' default: ''
description: '[Orchestrator] Github private token to pull from github' description: '[CloudRunner] Github private token to pull from github'
gitAuthMode:
required: false
default: 'header'
description:
'[Orchestrator] How git authentication is configured. "header" (default) uses http.extraHeader so the token
never appears in clone URLs or git config. "url" embeds the token in clone URLs (legacy behavior).'
githubOwner: githubOwner:
required: false required: false
default: '' default: ''
description: '[Orchestrator] GitHub owner name or organization/team name' description: '[CloudRunner] GitHub owner name or organization/team name'
runAsHostUser: runAsHostUser:
required: false required: false
default: 'false' default: 'false'
@@ -155,146 +149,97 @@ inputs:
allowDirtyBuild: allowDirtyBuild:
required: false required: false
default: '' default: ''
description: '[Orchestrator] Allows the branch of the build to be dirty, and still generate the build.' description: '[CloudRunner] Allows the branch of the build to be dirty, and still generate the build.'
postBuildSteps: postBuildSteps:
required: false required: false
default: '' default: ''
description: description:
'[Orchestrator] run a post build job in yaml format with the keys image, secrets (name, value object array), '[CloudRunner] run a post build job in yaml format with the keys image, secrets (name, value object array),
command string' command string'
preBuildSteps: preBuildSteps:
required: false required: false
default: '' default: ''
description: description:
'[Orchestrator] Run a pre build job after the repository setup but before the build job (in yaml format with the '[CloudRunner] Run a pre build job after the repository setup but before the build job (in yaml format with the
keys image, secrets (name, value object array), command line string)' keys image, secrets (name, value object array), command line string)'
containerHookFiles: containerHookFiles:
required: false required: false
default: '' default: ''
description: description:
'[Orchestrator] Specify the names (by file name) of custom steps to run before or after orchestrator jobs, must '[CloudRunner] Specify the names (by file name) of custom steps to run before or after cloud runner jobs, must
match a yaml step file inside your repo in the folder .game-ci/steps/' match a yaml step file inside your repo in the folder .game-ci/steps/'
customHookFiles: customHookFiles:
required: false required: false
default: '' default: ''
description: description:
'[Orchestrator] Specify the names (by file name) of custom hooks to run before or after orchestrator jobs, must '[CloudRunner] Specify the names (by file name) of custom hooks to run before or after cloud runner jobs, must
match a yaml step file inside your repo in the folder .game-ci/hooks/' match a yaml step file inside your repo in the folder .game-ci/hooks/'
customCommandHooks: customCommandHooks:
required: false required: false
default: '' default: ''
description: '[Orchestrator] Specify custom commands and trigger hooks (injects commands into jobs)' description: '[CloudRunner] Specify custom commands and trigger hooks (injects commands into jobs)'
customJob: customJob:
required: false required: false
default: '' default: ''
description: description:
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with '[CloudRunner] Run a custom job instead of the standard build automation for cloud runner (in yaml format with the
the keys image, secrets (name, value object array), command line string)' keys image, secrets (name, value object array), command line string)'
awsStackName: awsStackName:
default: 'game-ci' default: 'game-ci'
required: false required: false
description: '[Orchestrator] The Cloud Formation stack name that must be setup before using this option.' description: '[CloudRunner] The Cloud Formation stack name that must be setup before using this option.'
providerStrategy: providerStrategy:
default: 'local' default: 'local'
required: false required: false
description: description:
'[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must '[CloudRunner] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
be configured.' be configured.'
fallbackProviderStrategy:
default: ''
required: false
description:
'[Orchestrator] Fallback provider when the primary is unavailable. Used with runnerCheckEnabled for automatic
failover, or as a catch-all if the primary provider fails to initialize.'
runnerCheckEnabled:
default: 'false'
required: false
description:
'[Orchestrator] Check GitHub Actions runner availability before starting a build. When no suitable runners are
available and fallbackProviderStrategy is set, automatically routes to the fallback provider.'
runnerCheckLabels:
default: ''
required: false
description:
'[Orchestrator] Comma-separated runner labels to filter when checking availability (e.g. self-hosted,linux).
When empty, checks all runners in the repository.'
runnerCheckMinAvailable:
default: '1'
required: false
description:
'[Orchestrator] Minimum number of idle runners required for the primary provider. If fewer are available,
routes to fallbackProviderStrategy.'
retryOnFallback:
default: 'false'
required: false
description:
'[Orchestrator] When true and fallbackProviderStrategy is set, automatically retry the build on the fallback
provider if the primary provider fails. Useful for long builds where transient cloud failures are common.'
providerInitTimeout:
default: '0'
required: false
description:
'[Orchestrator] Maximum seconds to wait for the primary provider to initialize (setupWorkflow). If exceeded
and fallbackProviderStrategy is set, switches to the fallback. Set to 0 to disable (default).'
secretSource:
default: ''
required: false
description:
'[Orchestrator] Premade secret source for pulling build secrets. Supported values: aws-secrets-manager,
aws-parameter-store, gcp-secret-manager, azure-key-vault, hashicorp-vault, hashicorp-vault-kv1,
vault (alias for hashicorp-vault), env. Can also be a custom shell command with {0} placeholder
for the key, or a path to a YAML file defining custom sources. Takes precedence over
inputPullCommand when set.'
resourceTracking:
default: 'false'
required: false
description: '[Orchestrator] Enable resource tracking logs for disk usage and allocation summaries.'
containerCpu: containerCpu:
default: '' default: ''
required: false required: false
description: '[Orchestrator] Amount of CPU time to assign the remote build container' description: '[CloudRunner] Amount of CPU time to assign the remote build container'
containerMemory: containerMemory:
default: '' default: ''
required: false required: false
description: '[Orchestrator] Amount of memory to assign the remote build container' description: '[CloudRunner] Amount of memory to assign the remote build container'
readInputFromOverrideList: readInputFromOverrideList:
default: '' default: ''
required: false required: false
description: '[Orchestrator] Comma separated list of input value names to read from "input override command"' description: '[CloudRunner] Comma separated list of input value names to read from "input override command"'
readInputOverrideCommand: readInputOverrideCommand:
default: '' default: ''
required: false required: false
description: description:
'[Orchestrator] Extend game ci by specifying a command to execute to pull input from external source e.g cloud '[CloudRunner] Extend game ci by specifying a command to execute to pull input from external source e.g cloud
provider secret managers' provider secret managers'
kubeConfig: kubeConfig:
default: '' default: ''
required: false required: false
description: description:
'[Orchestrator] Supply a base64 encoded kubernetes config to run builds on kubernetes and stream logs until '[CloudRunner] Supply a base64 encoded kubernetes config to run builds on kubernetes and stream logs until
completion.' completion.'
kubeVolume: kubeVolume:
default: '' default: ''
required: false required: false
description: '[Orchestrator] Supply a Persistent Volume Claim name to use for the Unity build.' description: '[CloudRunner] Supply a Persistent Volume Claim name to use for the Unity build.'
kubeStorageClass: kubeStorageClass:
default: '' default: ''
required: false required: false
description: description:
'[Orchestrator] Kubernetes storage class to use for orchestrator jobs, leave empty to install rook cluster.' '[CloudRunner] Kubernetes storage class to use for cloud runner jobs, leave empty to install rook cluster.'
kubeVolumeSize: kubeVolumeSize:
default: '5Gi' default: '5Gi'
required: false required: false
description: '[Orchestrator] Amount of disc space to assign the Kubernetes Persistent Volume' description: '[CloudRunner] Amount of disc space to assign the Kubernetes Persistent Volume'
cacheKey: cacheKey:
default: '' default: ''
required: false required: false
description: '[Orchestrator] Cache key to indicate bucket for cache' description: '[CloudRunner] Cache key to indicate bucket for cache'
watchToEnd: watchToEnd:
default: 'true' default: 'true'
required: false required: false
description: description:
'[Orchestrator] Whether or not to watch the build to the end. Can be used for especially long running jobs e.g '[CloudRunner] Whether or not to watch the build to the end. Can be used for especially long running jobs e.g
imports or self-hosted ephemeral runners.' imports or self-hosted ephemeral runners.'
cacheUnityInstallationOnMac: cacheUnityInstallationOnMac:
default: 'false' default: 'false'
@@ -320,408 +265,6 @@ inputs:
default: 'false' default: 'false'
required: false required: false
description: 'Skip the activation/deactivation of Unity. This assumes Unity is already activated.' description: 'Skip the activation/deactivation of Unity. This assumes Unity is already activated.'
artifactOutputTypes:
description: 'Comma-separated list of output types to collect (build, logs, test-results, coverage, images, metrics, data-export, server-build, custom)'
required: false
default: 'build,logs,test-results'
artifactUploadTarget:
description: 'Where to upload artifacts: github-artifacts, storage, local, none'
required: false
default: 'github-artifacts'
artifactUploadPath:
description: 'Destination path for artifact upload (storage URI or local path)'
required: false
artifactCompression:
description: 'Compression for artifacts: none, gzip, lz4'
required: false
default: 'gzip'
artifactRetentionDays:
description: 'Retention period for uploaded artifacts in days'
required: false
default: '30'
artifactCustomTypes:
description: 'JSON string defining custom output types [{name, defaultPath, description}]'
required: false
cloneDepth:
default: '50'
required: false
description: '[Orchestrator] Specifies the depth of the git clone for the repository. Use 0 for full clone.'
orchestratorRepoName:
default: 'game-ci/unity-builder'
required: false
description:
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
fixes.'
submoduleProfilePath:
required: false
default: ''
description:
'Path to a YAML submodule profile file (relative to repo root). Defines which submodules to initialize (branch:
main) or skip (branch: empty). See docs for format.'
submoduleVariantPath:
required: false
default: ''
description:
'Path to a YAML variant overlay file that modifies the base submodule profile. Used for server or debug build
variants.'
submoduleToken:
required: false
default: ''
description:
'Git token for authenticating submodule clones. Falls back to gitPrivateToken or GITHUB_TOKEN if empty.'
localCacheEnabled:
required: false
default: 'false'
description:
'Enable filesystem-based caching for local builds. Caches the Unity Library folder and optionally LFS objects
between builds without requiring actions/cache.'
localCacheRoot:
required: false
default: ''
description:
'Root directory for local build cache. Defaults to $RUNNER_TEMP/game-ci-cache or .game-ci/cache if RUNNER_TEMP is
not set.'
localCacheLibrary:
required: false
default: 'true'
description: 'Cache the Unity Library folder for local builds. Only effective when localCacheEnabled is true.'
localCacheLfs:
required: false
default: 'false'
description: 'Cache Git LFS objects for local builds. Only effective when localCacheEnabled is true.'
childWorkspacesEnabled:
required: false
default: 'false'
description:
'Enable child workspace isolation for multi-product builds. Uses atomic filesystem moves for O(1) workspace
restore instead of tar/download/extract. Ideal for 50GB+ workspaces on self-hosted runners.'
childWorkspaceName:
required: false
default: ''
description:
'Name for this child workspace (e.g., product name like "TurnOfWar"). Used as the cache key for workspace
isolation. Required when childWorkspacesEnabled is true.'
childWorkspaceCacheRoot:
required: false
default: ''
description:
'Parent directory for cached child workspaces. Should be on the same NTFS volume as the build directory for O(1)
atomic restore via filesystem rename. Defaults to $RUNNER_TEMP/game-ci-workspaces.'
childWorkspacePreserveGit:
required: false
default: 'true'
description:
'Preserve .git directory in cached child workspace. Enables delta operations on restore but increases cache size.
Set to false to save disk space at the cost of full re-clone on restore.'
childWorkspaceSeparateLibrary:
required: false
default: 'true'
description:
'Cache Unity Library folder separately from the child workspace. Allows independent Library restore even when
workspace cache is invalidated. Recommended for large projects.'
lfsTransferAgent:
required: false
default: ''
description:
'Custom Git LFS transfer agent. Set to "elastic-git-storage" for built-in support (auto-installs from GitHub
releases). Append @version for a specific release (e.g. "elastic-git-storage@v1.0.0"). Or provide a path to any
custom transfer agent executable. When set, the agent is registered via git config before LFS operations.'
lfsTransferAgentArgs:
required: false
default: ''
description: 'Additional arguments to pass to the custom LFS transfer agent.'
lfsStoragePaths:
required: false
default: ''
description:
'Semicolon-separated list of storage paths for the custom LFS transfer agent. Interpretation depends on the agent
(e.g. local paths, WebDAV URLs, rclone remotes).'
gitHooksEnabled:
required: false
default: 'false'
description:
'Install and run git hooks (lefthook, husky, or native) during builds. When false (default), hooks are disabled
for build performance.'
gitHooksSkipList:
required: false
default: ''
description:
'Comma-separated list of hook names to skip even when gitHooksEnabled is true. Example: pre-push,post-merge'
gitHooksRunBeforeBuild:
required: false
default: ''
description:
'Comma-separated list of lefthook hook groups to run before the Unity build. Allows CI to trigger checks that
normally only run on git events. Example: pre-commit,pre-push. Requires lefthook. Works with Unity Git Hooks
(com.frostebite.unitygithooks) when installed as a UPM package — the init script runs automatically.'
providerExecutable:
required: false
default: ''
description:
'Path to an external CLI executable that implements the provider protocol. Enables providers written in any
language (Go, Python, Rust, shell). Uses JSON-over-stdin/stdout communication.'
gcpProject:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Google Cloud project ID for Cloud Run Jobs provider. Falls back to
GOOGLE_CLOUD_PROJECT env var.'
gcpRegion:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Google Cloud region for Cloud Run Jobs (e.g. us-central1). Defaults to the region
input if empty.'
gcpStorageType:
required: false
default: 'gcs-fuse'
description:
'[Orchestrator] [Experimental] Storage type for Cloud Run Jobs. Options: gcs-fuse (mount GCS bucket as filesystem,
unlimited size, best for large sequential I/O), gcs-copy (copy artifacts in/out via gsutil, simpler, no FUSE
overhead), nfs (Filestore NFS mount, true POSIX, good random I/O, up to 100 TiB), in-memory (tmpfs, fastest but
volatile, up to 32 GiB).'
gcpBucket:
required: false
default: ''
description:
'[Orchestrator] [Experimental] GCS bucket name for build artifact storage. Used by gcs-fuse and gcs-copy storage
types.'
gcpFilestoreIp:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Filestore instance IP address for NFS storage type. Required when gcpStorageType is
nfs.'
gcpFilestoreShare:
required: false
default: '/share1'
description:
'[Orchestrator] [Experimental] Filestore share name for NFS storage type. Defaults to /share1 (the Filestore
default).'
gcpMachineType:
required: false
default: 'e2-standard-4'
description: '[Orchestrator] [Experimental] Machine type for Cloud Run Jobs (e.g. e2-standard-4, e2-highmem-8).'
gcpDiskSizeGb:
required: false
default: '100'
description:
'[Orchestrator] [Experimental] Disk size in GB for Cloud Run Jobs in-memory volumes. Only applies to in-memory
storage type (max 32).'
gcpServiceAccount:
required: false
default: ''
description: '[Orchestrator] [Experimental] Google Cloud service account email for Cloud Run Jobs execution.'
gcpVpcConnector:
required: false
default: ''
description: '[Orchestrator] [Experimental] VPC connector name for Cloud Run Jobs private networking.'
azureResourceGroup:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure resource group for Container Instances provider. Falls back to
AZURE_RESOURCE_GROUP env var.'
azureLocation:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure region for Container Instances (e.g. eastus, westeurope). Defaults to the
region input if empty.'
azureStorageType:
required: false
default: 'azure-files'
description:
'[Orchestrator] [Experimental] Storage type for Azure Container Instances. Options: azure-files (SMB file share
mount, up to 100 TiB, premium throughput), blob-copy (copy artifacts in/out via az storage blob, no mount
overhead), azure-files-nfs (NFS 4.1 file share mount, true POSIX, no SMB lock overhead), in-memory (emptyDir
tmpfs, fastest but volatile, size limited by container memory).'
azureStorageAccount:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure Storage Account name. Used by azure-files, azure-files-nfs, and blob-copy
storage types.'
azureFileShareName:
required: false
default: 'unity-builds'
description:
'[Orchestrator] [Experimental] Azure File Share name within the storage account. Used by azure-files and
azure-files-nfs storage types. Supports up to 100 TiB per share.'
azureBlobContainer:
required: false
default: 'unity-builds'
description: '[Orchestrator] [Experimental] Azure Blob container name for blob-copy storage type.'
azureSubscriptionId:
required: false
default: ''
description: '[Orchestrator] [Experimental] Azure subscription ID. Falls back to AZURE_SUBSCRIPTION_ID env var.'
azureCpu:
required: false
default: '4'
description: '[Orchestrator] [Experimental] CPU cores for Azure Container Instances (1-16).'
azureMemoryGb:
required: false
default: '16'
description: '[Orchestrator] [Experimental] Memory in GB for Azure Container Instances (1-16).'
azureDiskSizeGb:
required: false
default: '100'
description:
'[Orchestrator] [Experimental] File share quota in GB for Azure Container Instances. Premium shares support up to
102400 GB (100 TiB).'
azureSubnetId:
required: false
default: ''
description: '[Orchestrator] [Experimental] Azure subnet resource ID for VNet-integrated Container Instances.'
remotePowershellHost:
default: ''
required: false
description: '[Orchestrator] Remote PowerShell host (hostname or IP) for the remote-powershell provider'
remotePowershellCredential:
default: ''
required: false
description: '[Orchestrator] Remote PowerShell credential (username:password or certificate path)'
remotePowershellTransport:
default: 'wsman'
required: false
description: '[Orchestrator] Remote PowerShell transport protocol (wsman or ssh)'
githubActionsRepo:
default: ''
required: false
description: '[Orchestrator] Target repository (owner/repo) for the github-actions provider'
githubActionsWorkflow:
default: ''
required: false
description: '[Orchestrator] Workflow filename or ID to dispatch for the github-actions provider'
githubActionsToken:
default: ''
required: false
description: '[Orchestrator] PAT with actions:write scope for the github-actions provider'
githubActionsRef:
default: 'main'
required: false
description: '[Orchestrator] Branch/ref to run the workflow on for the github-actions provider'
gitlabProjectId:
default: ''
required: false
description: '[Orchestrator] GitLab project ID or URL-encoded path for the gitlab-ci provider'
gitlabTriggerToken:
default: ''
required: false
description: '[Orchestrator] Pipeline trigger token for the gitlab-ci provider'
gitlabApiUrl:
default: 'https://gitlab.com'
required: false
description: '[Orchestrator] GitLab API URL (for self-hosted instances) for the gitlab-ci provider'
gitlabRef:
default: 'main'
required: false
description: '[Orchestrator] Branch/ref to trigger the pipeline on for the gitlab-ci provider'
ansibleInventory:
default: ''
required: false
description: '[Orchestrator] Path to Ansible inventory file or dynamic inventory script'
ansiblePlaybook:
default: ''
required: false
description: '[Orchestrator] Path to Ansible playbook for Unity builds'
ansibleExtraVars:
default: ''
required: false
description: '[Orchestrator] Additional Ansible variables as JSON'
ansibleVaultPassword:
default: ''
required: false
description: '[Orchestrator] Path to Ansible vault password file'
gitIntegrityCheck:
description: 'Run git integrity checks before build (fsck, lock cleanup, submodule validation)'
required: false
default: 'false'
gitAutoRecover:
description: 'Attempt automatic recovery if git corruption is detected'
required: false
default: 'false'
cleanReservedFilenames:
description: 'Remove Windows reserved filenames that cause Unity import loops'
required: false
default: 'false'
buildArchiveEnabled:
description: 'Archive build output after successful build'
required: false
default: 'false'
buildArchivePath:
description: 'Path to store build archives'
required: false
default: './build-archives'
buildArchiveRetention:
description: 'Days to retain build archives before cleanup'
required: false
default: '30'
testSuitePath:
description: 'Path to YAML test suite definition file'
required: false
testSuiteEvent:
description: 'CI event name for suite selection (pr, push, release)'
required: false
testTaxonomyPath:
description: 'Path to custom taxonomy definition YAML'
required: false
testResultFormat:
description: 'Test result output format: junit, json, or both'
required: false
default: 'junit'
testResultPath:
description: 'Directory for structured test result output'
required: false
default: './test-results'
hotRunnerEnabled:
description: '[HotRunner] Use persistent hot runner for builds (requires pre-registered runners)'
required: false
default: 'false'
hotRunnerTransport:
description: '[HotRunner] Transport protocol for hot runner communication: websocket, grpc, named-pipe'
required: false
default: 'websocket'
hotRunnerHost:
description: '[HotRunner] Hot runner host address'
required: false
default: 'localhost'
hotRunnerPort:
description: '[HotRunner] Hot runner port number'
required: false
default: '9090'
hotRunnerHealthInterval:
description: '[HotRunner] Health check interval in seconds'
required: false
default: '30'
hotRunnerMaxIdle:
description: '[HotRunner] Maximum idle time in seconds before recycling runner'
required: false
default: '3600'
hotRunnerFallbackToCold:
description: '[HotRunner] Fall back to cold build if no hot runner available'
required: false
default: 'true'
syncStrategy:
description: 'Workspace sync strategy: full, git-delta, direct-input, storage-pull'
required: false
default: 'full'
syncInputRef:
description: 'URI for direct-input or storage-pull content (storage://remote/path or file path)'
required: false
syncStorageRemote:
description: 'rclone remote name for storage-backed inputs (defaults to rcloneRemote)'
required: false
syncRevertAfter:
description: 'Revert overlaid changes after job completion'
required: false
default: 'true'
syncStatePath:
description: 'Path to sync state file for delta tracking'
required: false
default: '.game-ci/sync-state.json'
outputs: outputs:
volume: volume:
@@ -735,8 +278,6 @@ outputs:
'Returns the exit code from the build scripts. This code is 0 if the build was successful. If there was an error '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 during activation, the code is from the activation step. If activation is successful, the code is from the project
build step.' build step.'
artifactManifestPath:
description: 'Path to the generated artifact manifest JSON file'
branding: branding:
icon: 'box' icon: 'box'
color: 'gray-dark' color: 'gray-dark'
-27
View File
@@ -1,27 +0,0 @@
# Community Plugin Validation Registry
# Packages listed here are automatically tested on a schedule
# to ensure compatibility with unity-builder.
#
# Format:
# - name: Human-readable name
# package: UPM package name or git URL
# source: upm | git | asset-store
# unity: Minimum Unity version (optional, defaults to 2021.3)
# platforms: List of platforms to test (optional, defaults to [StandaloneLinux64])
# timeout: Build timeout in minutes (optional, defaults to 30)
plugins:
# Example entries — community members can submit PRs to add their packages
- name: UniTask
package: https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask
source: git
platforms: [StandaloneLinux64, StandaloneWindows64]
- name: NaughtyAttributes
package: https://github.com/dbrizov/NaughtyAttributes.git?path=Assets/NaughtyAttributes
source: git
- name: Unity Atoms
package: https://github.com/unity-atoms/unity-atoms.git
source: git
platforms: [StandaloneLinux64]
@@ -1,138 +0,0 @@
# delete-me-update-all-integration-branches.ps1
# Updates ALL integration branches from their component branches.
# Run from any branch -- it will stash changes, update each integration branch, then return.
$ErrorActionPreference = 'Stop'
$originalBranch = git rev-parse --abbrev-ref HEAD
$stashed = $false
# Stash any uncommitted changes
$status = git status --porcelain
if ($status) {
Write-Host "Stashing uncommitted changes..." -ForegroundColor Cyan
git stash push -m "auto-stash before integration branch update"
$stashed = $true
}
Write-Host "Fetching all branches from origin..." -ForegroundColor Cyan
git fetch origin
$integrationBranches = @(
@{
Name = 'release/next-gen'
Branches = @(
'feature/test-workflow-engine'
'feature/hot-runner-protocol'
'feature/generic-artifact-system'
'feature/incremental-sync-protocol'
'feature/community-plugin-validation'
'feature/cli-support'
)
}
@{
Name = 'release/lts-infrastructure'
Branches = @(
'feature/orchestrator-enterprise-support'
'feature/cloud-run-azure-providers'
'feature/provider-load-balancing'
'feature/orchestrator-unit-tests'
'fix/secure-git-token-usage'
'feature/premade-secret-sources'
'feature/ci-platform-providers'
'feature/build-reliability'
'ci/orchestrator-integrity-speedup'
)
}
@{
Name = 'release/lts-2.0.0'
Branches = @(
# Infrastructure
'feature/orchestrator-enterprise-support'
'feature/cloud-run-azure-providers'
'feature/provider-load-balancing'
'feature/orchestrator-unit-tests'
'fix/secure-git-token-usage'
'feature/premade-secret-sources'
'feature/ci-platform-providers'
'feature/build-reliability'
'ci/orchestrator-integrity-speedup'
# Next-gen
'feature/test-workflow-engine'
'feature/hot-runner-protocol'
'feature/generic-artifact-system'
'feature/incremental-sync-protocol'
'feature/community-plugin-validation'
'feature/cli-support'
)
}
)
foreach ($integration in $integrationBranches) {
$name = $integration.Name
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host "Updating $name" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
# Check if branch exists locally
$exists = git branch --list $name
if (-not $exists) {
Write-Host "Creating local branch from origin/$name..." -ForegroundColor Yellow
git checkout -b $name "origin/$name"
} else {
git checkout $name
git pull origin $name --ff-only 2>$null
if ($LASTEXITCODE -ne 0) {
git pull origin $name --no-edit
}
}
$failed = @()
foreach ($branch in $integration.Branches) {
$remoteBranch = "origin/$branch"
# Check if remote branch exists
$refExists = git rev-parse --verify $remoteBranch 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Host " Skipping $branch (not found on remote)" -ForegroundColor DarkGray
continue
}
# Check if already merged
$mergeBase = git merge-base HEAD $remoteBranch 2>$null
$remoteHead = git rev-parse $remoteBranch 2>$null
if ($mergeBase -eq $remoteHead) {
Write-Host " $branch - already up to date" -ForegroundColor DarkGray
continue
}
Write-Host " Merging $branch..." -ForegroundColor Yellow
$result = git merge $remoteBranch --no-edit 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host " CONFLICT - skipped (resolve manually)" -ForegroundColor Red
$failed += $branch
git merge --abort
} else {
Write-Host " OK" -ForegroundColor Green
}
}
if ($failed.Count -gt 0) {
Write-Host "`n Conflicts in:" -ForegroundColor Red
$failed | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
}
# Push
Write-Host " Pushing $name to origin..." -ForegroundColor Cyan
git push origin $name
}
# Return to original branch
Write-Host "`nReturning to $originalBranch..." -ForegroundColor Cyan
git checkout $originalBranch
if ($stashed) {
Write-Host "Restoring stashed changes..." -ForegroundColor Cyan
git stash pop
}
Write-Host "`nDone!" -ForegroundColor Green
@@ -1,60 +0,0 @@
# delete-me-update-this-integration-branch.ps1
# Run this script from the repo root while on the release/lts-2.0.0 branch.
# It merges the latest from each component branch to keep this integration branch current.
# After running, review any conflicts, then commit and push.
$ErrorActionPreference = 'Stop'
$branchName = git rev-parse --abbrev-ref HEAD
if ($branchName -ne 'release/lts-2.0.0') {
Write-Error "Must be on release/lts-2.0.0 branch. Currently on: $branchName"
exit 1
}
# Component branches for this integration branch
$branches = @(
# Infrastructure
'feature/orchestrator-enterprise-support'
'feature/cloud-run-azure-providers'
'feature/provider-load-balancing'
'feature/orchestrator-unit-tests'
'fix/secure-git-token-usage'
'feature/premade-secret-sources'
'feature/ci-platform-providers'
'feature/build-reliability'
'ci/orchestrator-integrity-speedup'
# Next-gen
'feature/test-workflow-engine'
'feature/hot-runner-protocol'
'feature/generic-artifact-system'
'feature/incremental-sync-protocol'
'feature/community-plugin-validation'
'feature/cli-support'
)
Write-Host "Fetching latest from origin..." -ForegroundColor Cyan
git fetch origin
$failed = @()
foreach ($branch in $branches) {
Write-Host "`nMerging origin/$branch..." -ForegroundColor Yellow
$result = git merge "origin/$branch" --no-edit 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host " CONFLICT merging $branch - resolve manually" -ForegroundColor Red
$failed += $branch
# Abort this merge so we can continue with others
git merge --abort
} else {
Write-Host " Merged successfully" -ForegroundColor Green
}
}
if ($failed.Count -gt 0) {
Write-Host "`nThe following branches had conflicts and were skipped:" -ForegroundColor Red
$failed | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
Write-Host "`nRe-run after resolving, or merge them manually:" -ForegroundColor Yellow
$failed | ForEach-Object { Write-Host " git merge origin/$_" -ForegroundColor Yellow }
} else {
Write-Host "`nAll branches merged successfully!" -ForegroundColor Green
Write-Host "Run 'git push origin release/lts-2.0.0' to update the remote." -ForegroundColor Cyan
}
@@ -56,19 +56,14 @@ namespace UnityBuilderAction
// of either `UnityEditor.BuildPlayerOptions` or `UnityEditor.BuildPlayerWithProfileOptions` // of either `UnityEditor.BuildPlayerOptions` or `UnityEditor.BuildPlayerWithProfileOptions`
dynamic buildPlayerOptions; dynamic buildPlayerOptions;
if (options.TryGetValue("activeBuildProfile", out var buildProfilePath)) { if (options["customBuildProfile"] != "") {
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 #if UNITY_6000_0_OR_NEWER
// Load build profile from Assets folder // Load build profile from Assets folder
var buildProfile = AssetDatabase.LoadAssetAtPath<BuildProfile>(buildProfilePath) BuildProfile buildProfile = AssetDatabase.LoadAssetAtPath<BuildProfile>(options["customBuildProfile"]);
?? throw new Exception("Build profile file not found at path: " + buildProfilePath);
// no need to set active profile, as already set by `-activeBuildProfile` CLI argument // Set it as active
// BuildProfile.SetActiveBuildProfile(buildProfile); BuildProfile.SetActiveBuildProfile(buildProfile);
Debug.Log($"build profile: {buildProfile.name}");
// Define BuildPlayerWithProfileOptions // Define BuildPlayerWithProfileOptions
buildPlayerOptions = new BuildPlayerWithProfileOptions { buildPlayerOptions = new BuildPlayerWithProfileOptions {
@@ -76,16 +71,12 @@ namespace UnityBuilderAction
locationPathName = options["customBuildPath"], locationPathName = options["customBuildPath"],
options = buildOptions, options = buildOptions,
}; };
#else // UNITY_6000_0_OR_NEWER #else
throw new Exception("Build profiles are not supported by this version of Unity (" + Application.unityVersion +")"); throw new Exception("Build profiles are not supported by this version of Unity (" + Application.unityVersion +")");
#endif // UNITY_6000_0_OR_NEWER #endif
} else { } 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 // Gather values from project
var scenes = EditorBuildSettings.scenes.Where(scene => scene.enabled).Select(s => s.path).ToArray(); var scenes = EditorBuildSettings.scenes.Where(scene => scene.enabled).Select(s => s.path).ToArray();
@@ -115,7 +115,6 @@ namespace UnityBuilderAction.Input
} }
} }
#if UNITY_6000_0_OR_NEWER
private static void SetDebugSymbols(string enumValueName) private static void SetDebugSymbols(string enumValueName)
{ {
// UnityEditor.Android.UserBuildSettings and Unity.Android.Types.DebugSymbolLevel are part of the Unity Android module. // UnityEditor.Android.UserBuildSettings and Unity.Android.Types.DebugSymbolLevel are part of the Unity Android module.
@@ -145,6 +144,5 @@ namespace UnityBuilderAction.Input
} }
levelProp.SetValue(null, enumValue); levelProp.SetValue(null, enumValue);
} }
#endif
} }
} }
@@ -21,19 +21,6 @@ namespace UnityBuilderAction.Input
EditorApplication.Exit(110); 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; string buildTarget;
if (!validatedOptions.TryGetValue("buildTarget", out buildTarget)) { if (!validatedOptions.TryGetValue("buildTarget", out buildTarget)) {
Console.WriteLine("Missing argument -buildTarget"); Console.WriteLine("Missing argument -buildTarget");
@@ -44,7 +31,6 @@ namespace UnityBuilderAction.Input
Console.WriteLine(buildTarget + " is not a defined " + typeof(BuildTarget).Name); Console.WriteLine(buildTarget + " is not a defined " + typeof(BuildTarget).Name);
EditorApplication.Exit(121); EditorApplication.Exit(121);
} }
}
string customBuildPath; string customBuildPath;
if (!validatedOptions.TryGetValue("customBuildPath", out customBuildPath)) { if (!validatedOptions.TryGetValue("customBuildPath", out customBuildPath)) {
Generated Vendored
+30869 -37459
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+95 -102
View File
@@ -6865,6 +6865,76 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
@deno/shim-deno
MIT
MIT License
Copyright 2021-2022 the Deno authors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@deno/shim-deno-test
MIT
MIT License
Copyright 2021-2022 the Deno authors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@fastify/busboy
MIT
Copyright Brian White. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
@kubernetes/client-node @kubernetes/client-node
Apache-2.0 Apache-2.0
Apache License Apache License
@@ -17052,31 +17122,6 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE. IN THE SOFTWARE.
cross-fetch
MIT
The MIT License (MIT)
Copyright (c) 2017 Leonardo Quixadá
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
cross-spawn cross-spawn
MIT MIT
The MIT License (MIT) The MIT License (MIT)
@@ -17569,56 +17614,6 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
graphql
MIT
MIT License
Copyright (c) GraphQL Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
graphql-request
MIT
MIT License
Copyright (c) 2022 Jason Kuhrt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
har-schema har-schema
ISC ISC
Copyright (c) 2015, Ahmad Nassri <ahmad@ahmadnassri.com> Copyright (c) 2015, Ahmad Nassri <ahmad@ahmadnassri.com>
@@ -19412,33 +19407,6 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
shell-quote
MIT
The MIT License
Copyright (c) 2013 James Halliday (mail@substack.net)
Permission is hereby granted, free of charge,
to any person obtaining a copy of this software and
associated documentation files (the "Software"), to
deal in the Software without restriction, including
without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom
the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR
ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
shelljs shelljs
BSD-3-Clause BSD-3-Clause
Copyright (c) 2012, Artur Adib <arturadib@gmail.com> Copyright (c) 2012, Artur Adib <arturadib@gmail.com>
@@ -19773,6 +19741,31 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE. OTHER DEALINGS IN THE SOFTWARE.
undici
MIT
MIT License
Copyright (c) Matteo Collina and Undici contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
unity-changeset unity-changeset
MIT MIT
MIT License MIT License
+13 -61
View File
@@ -4,69 +4,21 @@
echo "Changing to \"$ACTIVATE_LICENSE_PATH\" directory." echo "Changing to \"$ACTIVATE_LICENSE_PATH\" directory."
pushd "$ACTIVATE_LICENSE_PATH" pushd "$ACTIVATE_LICENSE_PATH"
if [[ -n "$UNITY_SERIAL" && -n "$UNITY_EMAIL" && -n "$UNITY_PASSWORD" ]]; then echo "Requesting activation"
#
# SERIAL LICENSE MODE
#
# This will activate unity, using the serial activation process.
#
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 # Store the exit code from the verify command
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity \ UNITY_EXIT_CODE=$?
-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
# #
# Display information about the result # Display information about the result
+3 -2
View File
@@ -149,13 +149,14 @@ echo ""
$( [ "${MANUAL_EXIT}" == "true" ] || echo "-quit" ) \ $( [ "${MANUAL_EXIT}" == "true" ] || echo "-quit" ) \
-batchmode \ -batchmode \
$( [ "${ENABLE_GPU}" == "true" ] || echo "-nographics" ) \ $( [ "${ENABLE_GPU}" == "true" ] || echo "-nographics" ) \
-username "$UNITY_EMAIL" \
-password "$UNITY_PASSWORD" \
-customBuildName "$BUILD_NAME" \ -customBuildName "$BUILD_NAME" \
-projectPath "$UNITY_PROJECT_PATH" \ -projectPath "$UNITY_PROJECT_PATH" \
$( [ -z "$BUILD_PROFILE" ] && echo "-buildTarget $BUILD_TARGET") \ -buildTarget "$BUILD_TARGET" \
-customBuildTarget "$BUILD_TARGET" \ -customBuildTarget "$BUILD_TARGET" \
-customBuildPath "$CUSTOM_BUILD_PATH" \ -customBuildPath "$CUSTOM_BUILD_PATH" \
-customBuildProfile "$BUILD_PROFILE" \ -customBuildProfile "$BUILD_PROFILE" \
${BUILD_PROFILE:+-activeBuildProfile} ${BUILD_PROFILE:+"$BUILD_PROFILE"} \
-executeMethod "$BUILD_METHOD" \ -executeMethod "$BUILD_METHOD" \
-buildVersion "$VERSION" \ -buildVersion "$VERSION" \
-androidVersionCode "$ANDROID_VERSION_CODE" \ -androidVersionCode "$ANDROID_VERSION_CODE" \
+9 -23
View File
@@ -4,29 +4,15 @@
echo "Changing to \"$ACTIVATE_LICENSE_PATH\" directory." echo "Changing to \"$ACTIVATE_LICENSE_PATH\" directory."
pushd "$ACTIVATE_LICENSE_PATH" pushd "$ACTIVATE_LICENSE_PATH"
if [[ -n "$UNITY_LICENSING_SERVER" ]]; then /Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity \
# -logFile - \
# Return any floating license used. -batchmode \
# -nographics \
echo "Returning floating license: \"$FLOATING_LICENSE\"" -quit \
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/Frameworks/UnityLicensingClient.app/Contents/MacOS/Unity.Licensing.Client \ -username "$UNITY_EMAIL" \
--return-floating "$FLOATING_LICENSE" -password "$UNITY_PASSWORD" \
elif [[ -n "$UNITY_SERIAL" ]]; then -returnlicense \
# -projectPath "$ACTIVATE_LICENSE_PATH"
# 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
# Return to previous working directory # Return to previous working directory
popd popd
+5 -9
View File
@@ -68,18 +68,14 @@ elif [[ -n "$UNITY_LICENSING_SERVER" ]]; then
echo "Adding licensing server config" 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? /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 # Store the exit code from the verify command
UNITY_EXIT_CODE=$? 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 else
# #
# NO LICENSE ACTIVATION STRATEGY MATCHED # NO LICENSE ACTIVATION STRATEGY MATCHED
+1 -2
View File
@@ -125,11 +125,10 @@ unity-editor \
$( [ "${MANUAL_EXIT}" == "true" ] || echo "-quit" ) \ $( [ "${MANUAL_EXIT}" == "true" ] || echo "-quit" ) \
-customBuildName "$BUILD_NAME" \ -customBuildName "$BUILD_NAME" \
-projectPath "$UNITY_PROJECT_PATH" \ -projectPath "$UNITY_PROJECT_PATH" \
$( [ -z "$BUILD_PROFILE" ] && echo "-buildTarget $BUILD_TARGET" ) \ -buildTarget "$BUILD_TARGET" \
-customBuildTarget "$BUILD_TARGET" \ -customBuildTarget "$BUILD_TARGET" \
-customBuildPath "$CUSTOM_BUILD_PATH" \ -customBuildPath "$CUSTOM_BUILD_PATH" \
-customBuildProfile "$BUILD_PROFILE" \ -customBuildProfile "$BUILD_PROFILE" \
${BUILD_PROFILE:+-activeBuildProfile} ${BUILD_PROFILE:+"$BUILD_PROFILE"} \
-executeMethod "$BUILD_METHOD" \ -executeMethod "$BUILD_METHOD" \
-buildVersion "$VERSION" \ -buildVersion "$VERSION" \
-androidVersionCode "$ANDROID_VERSION_CODE" \ -androidVersionCode "$ANDROID_VERSION_CODE" \
+1 -7
View File
@@ -166,6 +166,7 @@ $unityArgs = @(
"-customBuildName", "`"$Env:BUILD_NAME`"", "-customBuildName", "`"$Env:BUILD_NAME`"",
"-projectPath", "`"$Env:UNITY_PROJECT_PATH`"", "-projectPath", "`"$Env:UNITY_PROJECT_PATH`"",
"-executeMethod", "`"$Env:BUILD_METHOD`"", "-executeMethod", "`"$Env:BUILD_METHOD`"",
"-buildTarget", "`"$Env:BUILD_TARGET`"",
"-customBuildTarget", "`"$Env:BUILD_TARGET`"", "-customBuildTarget", "`"$Env:BUILD_TARGET`"",
"-customBuildPath", "`"$Env:CUSTOM_BUILD_PATH`"", "-customBuildPath", "`"$Env:CUSTOM_BUILD_PATH`"",
"-customBuildProfile", "`"$Env:BUILD_PROFILE`"", "-customBuildProfile", "`"$Env:BUILD_PROFILE`"",
@@ -180,13 +181,6 @@ $unityArgs = @(
"-logfile", "-" "-logfile", "-"
) + $customParametersArray ) + $customParametersArray
if (-not $Env:BUILD_PROFILE) {
$unityArgs += @("-buildTarget", "`"$Env:BUILD_TARGET`"")
}
if ($Env:BUILD_PROFILE) {
$unityArgs += @("-activeBuildProfile", "`"$Env:BUILD_PROFILE`"")
}
# Remove null items as that will fail the Start-Process call # Remove null items as that will fail the Start-Process call
$unityArgs = $unityArgs | Where-Object { $_ -ne $null } $unityArgs = $unityArgs | Where-Object { $_ -ne $null }
-3
View File
@@ -18,9 +18,6 @@ regsvr32 C:\ProgramData\Microsoft\VisualStudio\Setup\x64\Microsoft.VisualStudio.
# Kill the regsvr process # Kill the regsvr process
Get-Process -Name regsvr32 | ForEach-Object { Stop-Process -Id $_.Id -Force } Get-Process -Name regsvr32 | ForEach-Object { Stop-Process -Id $_.Id -Force }
# Install Visual C++ 2013 Redistributables
. "c:\steps\install_vcredist13.ps1"
# Setup Git Credentials # Setup Git Credentials
. "c:\steps\set_gitcredential.ps1" . "c:\steps\set_gitcredential.ps1"
-11
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
-122
View File
@@ -1,122 +0,0 @@
# game-ci CLI installer for Windows
# Usage: irm https://raw.githubusercontent.com/game-ci/unity-builder/main/install.ps1 | iex
#
# Environment variables:
# GAME_CI_VERSION - Install a specific version (e.g., v2.0.0). Defaults to latest.
# GAME_CI_INSTALL - Installation directory. Defaults to $HOME\.game-ci\bin.
$ErrorActionPreference = 'Stop'
$Repo = "game-ci/unity-builder"
$InstallDir = if ($env:GAME_CI_INSTALL) { $env:GAME_CI_INSTALL } else { Join-Path $env:USERPROFILE ".game-ci\bin" }
$AssetName = "game-ci-windows-x64.exe"
$BinaryName = "game-ci.exe"
function Write-Info($Message) {
Write-Host "info: " -ForegroundColor Green -NoNewline
Write-Host $Message
}
function Write-Warn($Message) {
Write-Host "warn: " -ForegroundColor Yellow -NoNewline
Write-Host $Message
}
# Determine version
if ($env:GAME_CI_VERSION) {
$Version = $env:GAME_CI_VERSION
Write-Info "Using specified version: $Version"
} else {
Write-Info "Fetching latest release..."
try {
$Release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest"
$Version = $Release.tag_name
} catch {
Write-Host "error: Could not determine latest version. Check https://github.com/$Repo/releases" -ForegroundColor Red
exit 1
}
}
$DownloadUrl = "https://github.com/$Repo/releases/download/$Version/$AssetName"
$ChecksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt"
$BinaryPath = Join-Path $InstallDir $BinaryName
Write-Host ""
Write-Info "Installing game-ci $Version (windows-x64)"
Write-Info " from: $DownloadUrl"
Write-Info " to: $BinaryPath"
Write-Host ""
# Create install directory
if (-not (Test-Path $InstallDir)) {
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
}
# Download binary
try {
Invoke-WebRequest -Uri $DownloadUrl -OutFile $BinaryPath -UseBasicParsing
} catch {
if ($_.Exception.Response.StatusCode -eq 404) {
Write-Host "error: Release asset not found: $AssetName ($Version)" -ForegroundColor Red
Write-Host " Check available assets at https://github.com/$Repo/releases/tag/$Version" -ForegroundColor Red
} else {
Write-Host "error: Download failed: $_" -ForegroundColor Red
}
exit 1
}
# Verify checksum
try {
$Checksums = Invoke-WebRequest -Uri $ChecksumUrl -UseBasicParsing | Select-Object -ExpandProperty Content
$ExpectedLine = $Checksums -split "`n" | Where-Object { $_ -match $AssetName } | Select-Object -First 1
if ($ExpectedLine) {
$ExpectedHash = ($ExpectedLine -split '\s+')[0]
$ActualHash = (Get-FileHash -Path $BinaryPath -Algorithm SHA256).Hash.ToLower()
if ($ExpectedHash -eq $ActualHash) {
Write-Info "Checksum verified (SHA256)"
} else {
Write-Host "error: Checksum verification failed!" -ForegroundColor Red
Write-Host " Expected: $ExpectedHash" -ForegroundColor Red
Write-Host " Got: $ActualHash" -ForegroundColor Red
Remove-Item $BinaryPath -Force
exit 1
}
}
} catch {
# Checksums not available for this release; continue without verification
}
# Verify the binary works
try {
$VersionOutput = & $BinaryPath version 2>&1
Write-Info "Verified: $($VersionOutput | Select-Object -First 1)"
} catch {
Write-Warn "Binary downloaded but could not verify. It may still work."
}
Write-Host ""
Write-Host "game-ci installed successfully!" -ForegroundColor Green -BackgroundColor Black
Write-Host ""
# Check PATH and offer to add
$UserPath = [Environment]::GetEnvironmentVariable('PATH', 'User')
if ($UserPath -notlike "*$InstallDir*") {
Write-Warn "game-ci is not in your PATH."
Write-Host ""
Write-Host "To add it permanently, run:" -ForegroundColor Yellow
Write-Host ""
Write-Host " [Environment]::SetEnvironmentVariable('PATH', ""$InstallDir;"" + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')"
Write-Host ""
Write-Info "Then restart your terminal."
# Offer to add automatically
Write-Host ""
$AddToPath = Read-Host "Add to PATH now? (Y/n)"
if ($AddToPath -ne 'n' -and $AddToPath -ne 'N') {
[Environment]::SetEnvironmentVariable('PATH', "$InstallDir;$UserPath", 'User')
$env:PATH = "$InstallDir;$env:PATH"
Write-Info "Added to PATH. You can now run: game-ci --help"
}
} else {
Write-Info "game-ci is already in your PATH. Run: game-ci --help"
}
-196
View File
@@ -1,196 +0,0 @@
#!/bin/sh
# game-ci CLI installer
# Usage: curl -fsSL https://raw.githubusercontent.com/game-ci/unity-builder/main/install.sh | sh
#
# Environment variables:
# GAME_CI_VERSION - Install a specific version (e.g., v2.0.0). Defaults to latest.
# GAME_CI_INSTALL - Installation directory. Defaults to ~/.game-ci/bin.
set -e
REPO="game-ci/unity-builder"
INSTALL_DIR="${GAME_CI_INSTALL:-$HOME/.game-ci/bin}"
BINARY_NAME="game-ci"
# Colors (disabled if not a terminal)
if [ -t 1 ]; then
BOLD='\033[1m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
RED='\033[0;31m'
RESET='\033[0m'
else
BOLD=''
GREEN=''
YELLOW=''
RED=''
RESET=''
fi
info() {
printf "${GREEN}info${RESET}: %s\n" "$1"
}
warn() {
printf "${YELLOW}warn${RESET}: %s\n" "$1"
}
error() {
printf "${RED}error${RESET}: %s\n" "$1" >&2
exit 1
}
# Detect OS and architecture
detect_platform() {
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS" in
Linux*) PLATFORM="linux" ;;
Darwin*) PLATFORM="macos" ;;
MINGW*|MSYS*|CYGWIN*)
PLATFORM="windows"
warn "For Windows, consider using install.ps1 instead:"
warn " irm https://raw.githubusercontent.com/game-ci/unity-builder/main/install.ps1 | iex"
;;
*) error "Unsupported operating system: $OS" ;;
esac
case "$ARCH" in
x86_64|amd64) ARCH="x64" ;;
aarch64|arm64) ARCH="arm64" ;;
*) error "Unsupported architecture: $ARCH" ;;
esac
ASSET_NAME="game-ci-${PLATFORM}-${ARCH}"
if [ "$PLATFORM" = "windows" ]; then
ASSET_NAME="${ASSET_NAME}.exe"
BINARY_NAME="game-ci.exe"
fi
}
# Get latest release tag from GitHub API
get_latest_version() {
if [ -n "$GAME_CI_VERSION" ]; then
VERSION="$GAME_CI_VERSION"
info "Using specified version: $VERSION"
return
fi
info "Fetching latest release..."
if command -v curl > /dev/null 2>&1; then
VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
elif command -v wget > /dev/null 2>&1; then
VERSION=$(wget -qO- "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
else
error "Neither curl nor wget found. Please install one of them."
fi
if [ -z "$VERSION" ]; then
error "Could not determine latest version. Check https://github.com/${REPO}/releases"
fi
}
# Download and install the binary
install() {
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET_NAME}"
printf "\n"
info "Installing game-ci ${VERSION} (${PLATFORM}-${ARCH})"
info " from: ${DOWNLOAD_URL}"
info " to: ${INSTALL_DIR}/${BINARY_NAME}"
printf "\n"
mkdir -p "$INSTALL_DIR"
# Download with progress
if command -v curl > /dev/null 2>&1; then
HTTP_CODE=$(curl -fSL "$DOWNLOAD_URL" -o "${INSTALL_DIR}/${BINARY_NAME}" -w "%{http_code}" 2>/dev/null) || true
if [ "$HTTP_CODE" = "404" ]; then
error "Release asset not found: ${ASSET_NAME} (${VERSION}). Check available assets at https://github.com/${REPO}/releases/tag/${VERSION}"
elif [ ! -f "${INSTALL_DIR}/${BINARY_NAME}" ]; then
error "Download failed. URL: ${DOWNLOAD_URL}"
fi
elif command -v wget > /dev/null 2>&1; then
wget -q "$DOWNLOAD_URL" -O "${INSTALL_DIR}/${BINARY_NAME}" || error "Download failed. URL: ${DOWNLOAD_URL}"
fi
chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
# Verify the binary works
if "${INSTALL_DIR}/${BINARY_NAME}" version > /dev/null 2>&1; then
INSTALLED_VERSION=$("${INSTALL_DIR}/${BINARY_NAME}" version 2>&1 | head -1)
info "Verified: ${INSTALLED_VERSION}"
else
warn "Binary downloaded but could not verify. It may still work."
fi
printf "\n"
printf "${BOLD}game-ci installed successfully!${RESET}\n"
printf "\n"
# Check if install dir is in PATH
case ":$PATH:" in
*":${INSTALL_DIR}:"*)
info "game-ci is already in your PATH. Run: game-ci --help"
;;
*)
SHELL_NAME=$(basename "$SHELL" 2>/dev/null || echo "sh")
case "$SHELL_NAME" in
zsh) PROFILE="~/.zshrc" ;;
bash) PROFILE="~/.bashrc" ;;
fish) PROFILE="~/.config/fish/config.fish" ;;
*) PROFILE="~/.profile" ;;
esac
printf "${YELLOW}Add game-ci to your PATH by adding this to ${PROFILE}:${RESET}\n"
printf "\n"
if [ "$SHELL_NAME" = "fish" ]; then
printf " set -gx PATH \"%s\" \$PATH\n" "$INSTALL_DIR"
else
printf " export PATH=\"%s:\$PATH\"\n" "$INSTALL_DIR"
fi
printf "\n"
info "Then restart your shell or run: source ${PROFILE}"
;;
esac
}
# Verify checksum if checksums.txt is available
verify_checksum() {
if ! command -v sha256sum > /dev/null 2>&1; then
return 0
fi
CHECKSUM_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt"
CHECKSUMS=""
if command -v curl > /dev/null 2>&1; then
CHECKSUMS=$(curl -fsSL "$CHECKSUM_URL" 2>/dev/null) || return 0
elif command -v wget > /dev/null 2>&1; then
CHECKSUMS=$(wget -qO- "$CHECKSUM_URL" 2>/dev/null) || return 0
fi
if [ -z "$CHECKSUMS" ]; then
return 0
fi
EXPECTED=$(echo "$CHECKSUMS" | grep "$ASSET_NAME" | awk '{print $1}')
if [ -z "$EXPECTED" ]; then
return 0
fi
ACTUAL=$(sha256sum "${INSTALL_DIR}/${BINARY_NAME}" | awk '{print $1}')
if [ "$EXPECTED" != "$ACTUAL" ]; then
error "Checksum verification failed!\n Expected: ${EXPECTED}\n Got: ${ACTUAL}"
fi
info "Checksum verified (SHA256)"
}
# Main
detect_platform
get_latest_version
install
verify_checksum
+9 -32
View File
@@ -3,24 +3,6 @@
"version": "3.0.0", "version": "3.0.0",
"description": "Build Unity projects for different platforms.", "description": "Build Unity projects for different platforms.",
"main": "dist/index.js", "main": "dist/index.js",
"bin": {
"game-ci": "./lib/cli.js"
},
"pkg": {
"scripts": "lib/**/*.js",
"assets": [
"lib/**/*.json",
"package.json"
],
"targets": [
"node20-linux-x64",
"node20-linux-arm64",
"node20-macos-x64",
"node20-macos-arm64",
"node20-win-x64"
],
"outputPath": "dist-binaries"
},
"repository": "git@github.com:game-ci/unity-builder.git", "repository": "git@github.com:game-ci/unity-builder.git",
"author": "Webber <webber@takken.io>", "author": "Webber <webber@takken.io>",
"license": "MIT", "license": "MIT",
@@ -30,19 +12,18 @@
"lint": "prettier --check \"src/**/*.{js,ts}\" && eslint src/**/*.ts", "lint": "prettier --check \"src/**/*.{js,ts}\" && eslint src/**/*.ts",
"format": "prettier --write \"src/**/*.{js,ts}\"", "format": "prettier --write \"src/**/*.{js,ts}\"",
"cli": "yarn ts-node src/index.ts -m cli", "cli": "yarn ts-node src/index.ts -m cli",
"game-ci": "ts-node src/cli.ts", "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-tests": "cross-env providerStrategy=aws orchestratorTests=true inputPullCommand=\"gcp-secret-manager\" populateOverride=true pullInputList=UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD yarn test -i -t \"orchestrator\"", "gcp-secrets-cli": "cross-env 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",
"gcp-secrets-cli": "cross-env orchestratorTests=true USE_IL2CPP=false inputPullCommand=\"gcp-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD", "aws-secrets-cli": "cross-env cloudRunnerTests=true inputPullCommand=\"aws-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
"aws-secrets-cli": "cross-env orchestratorTests=true inputPullCommand=\"aws-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
"cli-aws": "cross-env providerStrategy=aws yarn run test-cli", "cli-aws": "cross-env providerStrategy=aws yarn run test-cli",
"cli-k8s": "cross-env providerStrategy=k8s yarn run test-cli", "cli-k8s": "cross-env providerStrategy=k8s yarn run test-cli",
"test-cli": "cross-env orchestratorTests=true yarn ts-node src/index.ts -m cli --projectPath test-project", "test-cli": "cross-env cloudRunnerTests=true yarn ts-node src/index.ts -m cli --projectPath test-project",
"test": "jest", "test": "jest",
"test:ci": "jest --config=jest.ci.config.js --runInBand", "test:ci": "jest --config=jest.ci.config.js --runInBand",
"test-i": "cross-env orchestratorTests=true yarn test -i -t \"orchestrator\"", "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-*": "yarn run test-i-aws && yarn run test-i-k8s",
"test-i-aws": "cross-env orchestratorTests=true providerStrategy=aws yarn test -i -t \"orchestrator\"", "test-i-aws": "cross-env cloudRunnerTests=true providerStrategy=aws yarn test -i -t \"cloud runner\"",
"test-i-k8s": "cross-env orchestratorTests=true providerStrategy=k8s yarn test -i -t \"orchestrator\"" "test-i-k8s": "cross-env cloudRunnerTests=true providerStrategy=k8s yarn test -i -t \"cloud runner\""
}, },
"engines": { "engines": {
"node": ">=18.x" "node": ">=18.x"
@@ -69,12 +50,10 @@
"nanoid": "^3.3.1", "nanoid": "^3.3.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"semver": "^7.5.2", "semver": "^7.5.2",
"shell-quote": "^1.8.3",
"ts-md5": "^1.3.1", "ts-md5": "^1.3.1",
"unity-changeset": "^3.1.0", "unity-changeset": "^2.0.0",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"yaml": "^2.2.2", "yaml": "^2.2.2"
"yargs": "^17.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/base-64": "^1.0.0", "@types/base-64": "^1.0.0",
@@ -82,7 +61,6 @@
"@types/node": "^17.0.23", "@types/node": "^17.0.23",
"@types/semver": "^7.3.9", "@types/semver": "^7.3.9",
"@types/uuid": "^9.0.0", "@types/uuid": "^9.0.0",
"@types/yargs": "^17.0.35",
"@typescript-eslint/parser": "4.8.1", "@typescript-eslint/parser": "4.8.1",
"@vercel/ncc": "^0.36.1", "@vercel/ncc": "^0.36.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
@@ -98,7 +76,6 @@
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lefthook": "^1.6.1", "lefthook": "^1.6.1",
"node-fetch": "2", "node-fetch": "2",
"pkg": "^5.8.1",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"ts-jest": "^27.1.3", "ts-jest": "^27.1.3",
"ts-node": "10.8.1", "ts-node": "10.8.1",
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env node
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import buildCommand from './cli/commands/build';
import activateCommand from './cli/commands/activate';
import orchestrateCommand from './cli/commands/orchestrate';
import statusCommand from './cli/commands/status';
import versionCommand from './cli/commands/version';
import updateCommand from './cli/commands/update';
import * as core from '@actions/core';
const cli = yargs(hideBin(process.argv))
.scriptName('game-ci')
.usage('$0 <command> [options]')
.command(buildCommand)
.command(activateCommand)
.command(orchestrateCommand)
.command(statusCommand)
.command(versionCommand)
.command(updateCommand)
.demandCommand(1, 'You must specify a command. Run game-ci --help for available commands.')
.strict()
.alias('h', 'help')
.epilogue('For more information, visit https://game.ci')
.wrap(Math.min(120, process.stdout.columns || 80));
async function main() {
try {
await cli.parse();
} catch (error: any) {
if (error.name !== 'YError') {
core.error(`Error: ${error.message}`);
process.exit(1);
}
}
}
main();
-110
View File
@@ -1,110 +0,0 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
/**
* Integration tests that spawn the CLI as a child process and verify
* exit codes and output. Uses node with --require ts-node/register to
* run the TypeScript entry point directly so no build step is required.
*/
const CLI_ENTRY = path.resolve(__dirname, '..', '..', 'cli.ts');
function runCli(cliArguments: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve) => {
execFile(
process.execPath,
['--require', 'ts-node/register/transpile-only', CLI_ENTRY, ...cliArguments],
{ timeout: 30_000, cwd: path.resolve(__dirname, '..', '..', '..') },
(error, stdout, stderr) => {
resolve({
code: error ? error.code ?? 1 : 0,
stdout: stdout.toString(),
stderr: stderr.toString(),
});
},
);
});
}
// Integration tests spawn child processes which need more time than the default 5s
jest.setTimeout(30_000);
describe('CLI integration', () => {
it('exits 0 and shows all commands for --help', async () => {
const result = await runCli(['--help']);
expect(result.code).toStrictEqual(0);
expect(result.stdout).toContain('game-ci');
expect(result.stdout).toContain('build');
expect(result.stdout).toContain('activate');
expect(result.stdout).toContain('orchestrate');
expect(result.stdout).toContain('status');
expect(result.stdout).toContain('version');
expect(result.stdout).toContain('update');
});
it('exits 0 and shows version info for version command', async () => {
const result = await runCli(['version']);
expect(result.code).toStrictEqual(0);
expect(result.stdout).toContain('unity-builder');
});
it('exits 0 and shows build flags for build --help', async () => {
const result = await runCli(['build', '--help']);
expect(result.code).toStrictEqual(0);
expect(result.stdout).toContain('--target-platform');
expect(result.stdout).toContain('--unity-version');
expect(result.stdout).toContain('--project-path');
expect(result.stdout).toContain('--build-name');
expect(result.stdout).toContain('--builds-path');
expect(result.stdout).toContain('--build-method');
expect(result.stdout).toContain('--custom-parameters');
expect(result.stdout).toContain('--provider-strategy');
});
it('exits non-zero for an unknown command', async () => {
const result = await runCli(['nonexistent']);
expect(result.code).not.toStrictEqual(0);
});
it('exits non-zero when no command is provided', async () => {
const result = await runCli([]);
expect(result.code).not.toStrictEqual(0);
});
it('exits 0 for orchestrate --help', async () => {
const result = await runCli(['orchestrate', '--help']);
expect(result.code).toStrictEqual(0);
expect(result.stdout).toContain('--target-platform');
expect(result.stdout).toContain('--provider-strategy');
expect(result.stdout).toContain('cache');
});
it('exits 0 for activate --help', async () => {
const result = await runCli(['activate', '--help']);
expect(result.code).toStrictEqual(0);
expect(result.stdout).toContain('activate');
});
it('exits 0 for orchestrate cache --help', async () => {
const result = await runCli(['orchestrate', 'cache', '--help']);
expect(result.code).toStrictEqual(0);
expect(result.stdout).toContain('cache');
});
it('exits 0 for update --help', async () => {
const result = await runCli(['update', '--help']);
expect(result.code).toStrictEqual(0);
expect(result.stdout).toContain('update');
expect(result.stdout).toContain('--force');
expect(result.stdout).toContain('--version');
});
});
-245
View File
@@ -1,245 +0,0 @@
import buildCommand from '../commands/build';
import activateCommand from '../commands/activate';
import orchestrateCommand from '../commands/orchestrate';
import statusCommand from '../commands/status';
import versionCommand from '../commands/version';
import updateCommand from '../commands/update';
function createFakeYargs(): { yargs: any; options: Record<string, any> } {
const options: Record<string, any> = {};
const yargs: any = {
option: jest.fn(),
positional: jest.fn(),
example: jest.fn(),
env: jest.fn(),
command: jest.fn(),
};
yargs.option.mockImplementation((name: string, config: any) => {
options[name] = config;
return yargs;
});
yargs.positional.mockImplementation((name: string, config: any) => {
options[name] = config;
return yargs;
});
yargs.example.mockReturnValue(yargs);
yargs.env.mockReturnValue(yargs);
yargs.command.mockReturnValue(yargs);
return { yargs, options };
}
describe('CLI commands', () => {
describe('build command', () => {
it('exports the correct command name', () => {
expect(buildCommand.command).toStrictEqual('build');
});
it('has a description', () => {
expect(buildCommand.describe).toBeTruthy();
});
it('has a builder function', () => {
expect(typeof buildCommand.builder).toStrictEqual('function');
});
it('has a handler function', () => {
expect(typeof buildCommand.handler).toStrictEqual('function');
});
it('defines all expected build flags via builder', () => {
const { yargs, options } = createFakeYargs();
(buildCommand.builder as Function)(yargs);
// Core build flags
expect(options['target-platform']).toBeDefined();
expect(options['target-platform'].demandOption).toStrictEqual(true);
expect(options['unity-version']).toBeDefined();
expect(options['project-path']).toBeDefined();
expect(options['build-profile']).toBeDefined();
expect(options['build-name']).toBeDefined();
expect(options['builds-path']).toBeDefined();
expect(options['build-method']).toBeDefined();
expect(options['custom-parameters']).toBeDefined();
expect(options['versioning']).toBeDefined();
expect(options['version']).toBeDefined();
expect(options['custom-image']).toBeDefined();
expect(options['manual-exit']).toBeDefined();
expect(options['enable-gpu']).toBeDefined();
// Android flags
expect(options['android-version-code']).toBeDefined();
expect(options['android-export-type']).toBeDefined();
expect(options['android-keystore-name']).toBeDefined();
expect(options['android-keystore-base64']).toBeDefined();
expect(options['android-keystore-pass']).toBeDefined();
expect(options['android-keyalias-name']).toBeDefined();
expect(options['android-keyalias-pass']).toBeDefined();
expect(options['android-target-sdk-version']).toBeDefined();
expect(options['android-symbol-type']).toBeDefined();
// Docker flags
expect(options['docker-cpu-limit']).toBeDefined();
expect(options['docker-memory-limit']).toBeDefined();
expect(options['docker-workspace-path']).toBeDefined();
expect(options['run-as-host-user']).toBeDefined();
expect(options['chown-files-to']).toBeDefined();
// Provider flags
expect(options['provider-strategy']).toBeDefined();
expect(options['skip-activation']).toBeDefined();
expect(options['unity-licensing-server']).toBeDefined();
});
it('sets correct default values', () => {
const { yargs, options } = createFakeYargs();
(buildCommand.builder as Function)(yargs);
expect(options['unity-version'].default).toStrictEqual('auto');
expect(options['project-path'].default).toStrictEqual('.');
expect(options['builds-path'].default).toStrictEqual('build');
expect(options['versioning'].default).toStrictEqual('Semantic');
expect(options['manual-exit'].default).toStrictEqual(false);
expect(options['enable-gpu'].default).toStrictEqual(false);
expect(options['android-export-type'].default).toStrictEqual('androidPackage');
expect(options['android-symbol-type'].default).toStrictEqual('none');
expect(options['provider-strategy'].default).toStrictEqual('local');
});
it('provides camelCase aliases for kebab-case options', () => {
const { yargs, options } = createFakeYargs();
(buildCommand.builder as Function)(yargs);
expect(options['target-platform'].alias).toStrictEqual('targetPlatform');
expect(options['unity-version'].alias).toStrictEqual('unityVersion');
expect(options['project-path'].alias).toStrictEqual('projectPath');
expect(options['build-name'].alias).toStrictEqual('buildName');
expect(options['builds-path'].alias).toStrictEqual('buildsPath');
expect(options['build-method'].alias).toStrictEqual('buildMethod');
});
});
describe('activate command', () => {
it('exports the correct command name', () => {
expect(activateCommand.command).toStrictEqual('activate');
});
it('has a description', () => {
expect(activateCommand.describe).toBeTruthy();
});
it('has a builder function', () => {
expect(typeof activateCommand.builder).toStrictEqual('function');
});
it('has a handler function', () => {
expect(typeof activateCommand.handler).toStrictEqual('function');
});
});
describe('orchestrate command', () => {
it('exports the correct command name', () => {
expect(orchestrateCommand.command).toStrictEqual('orchestrate');
});
it('has a description', () => {
expect(orchestrateCommand.describe).toBeTruthy();
});
it('has a builder function', () => {
expect(typeof orchestrateCommand.builder).toStrictEqual('function');
});
it('has a handler function', () => {
expect(typeof orchestrateCommand.handler).toStrictEqual('function');
});
it('defines key orchestrator flags', () => {
const { yargs, options } = createFakeYargs();
(orchestrateCommand.builder as Function)(yargs);
expect(options['target-platform']).toBeDefined();
expect(options['provider-strategy']).toBeDefined();
expect(options['provider-strategy'].default).toStrictEqual('aws');
expect(options['aws-stack-name']).toBeDefined();
expect(options['kube-config']).toBeDefined();
expect(options['kube-volume']).toBeDefined();
expect(options['cache-key']).toBeDefined();
expect(options['watch-to-end']).toBeDefined();
expect(options['clone-depth']).toBeDefined();
});
it('registers cache as a subcommand', () => {
const { yargs } = createFakeYargs();
(orchestrateCommand.builder as Function)(yargs);
expect(yargs.command).toHaveBeenCalled();
});
});
describe('status command', () => {
it('exports the correct command name', () => {
expect(statusCommand.command).toStrictEqual('status');
});
it('has a description', () => {
expect(statusCommand.describe).toBeTruthy();
});
it('has a handler function', () => {
expect(typeof statusCommand.handler).toStrictEqual('function');
});
});
describe('version command', () => {
it('exports the correct command name', () => {
expect(versionCommand.command).toStrictEqual('version');
});
it('has a description', () => {
expect(versionCommand.describe).toBeTruthy();
});
it('has a handler function', () => {
expect(typeof versionCommand.handler).toStrictEqual('function');
});
});
describe('update command', () => {
it('exports the correct command name', () => {
expect(updateCommand.command).toStrictEqual('update');
});
it('has a description', () => {
expect(updateCommand.describe).toBeTruthy();
});
it('has a builder function', () => {
expect(typeof updateCommand.builder).toStrictEqual('function');
});
it('has a handler function', () => {
expect(typeof updateCommand.handler).toStrictEqual('function');
});
it('defines force and version flags', () => {
const { yargs, options } = createFakeYargs();
(updateCommand.builder as Function)(yargs);
expect(options['force']).toBeDefined();
expect(options['force'].type).toStrictEqual('boolean');
expect(options['force'].default).toStrictEqual(false);
expect(options['version']).toBeDefined();
expect(options['version'].type).toStrictEqual('string');
});
});
});
-221
View File
@@ -1,221 +0,0 @@
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
import { Cli } from '../../model/cli/cli';
import GitHub from '../../model/github';
afterEach(() => {
jest.restoreAllMocks();
Cli.options = undefined;
});
describe('mapCliArgumentsToInput', () => {
describe('basic mapping', () => {
it('populates Cli.options from CLI arguments', () => {
const cliArguments: CliArguments = {
targetPlatform: 'StandaloneLinux64',
unityVersion: '2022.3.56f1',
projectPath: './my-project',
};
mapCliArgumentsToInput(cliArguments);
expect(Cli.options).toBeDefined();
expect(Cli.options!['targetPlatform']).toStrictEqual('StandaloneLinux64');
expect(Cli.options!['unityVersion']).toStrictEqual('2022.3.56f1');
expect(Cli.options!['projectPath']).toStrictEqual('./my-project');
});
it('disables GitHub Actions input reading', () => {
const cliArguments: CliArguments = { targetPlatform: 'WebGL' };
mapCliArgumentsToInput(cliArguments);
expect(GitHub.githubInputEnabled).toStrictEqual(false);
});
it('sets mode to cli by default when not provided', () => {
const cliArguments: CliArguments = { targetPlatform: 'Android' };
mapCliArgumentsToInput(cliArguments);
expect(Cli.options!['mode']).toStrictEqual('cli');
});
it('preserves an explicitly provided mode', () => {
const cliArguments: CliArguments = { targetPlatform: 'Android', mode: 'custom-mode' };
mapCliArgumentsToInput(cliArguments);
expect(Cli.options!['mode']).toStrictEqual('custom-mode');
});
});
describe('default values', () => {
it('omits undefined values from Cli.options', () => {
const cliArguments: CliArguments = {
targetPlatform: 'StandaloneLinux64',
unityVersion: undefined,
buildName: undefined,
};
mapCliArgumentsToInput(cliArguments);
expect(Cli.options!['targetPlatform']).toStrictEqual('StandaloneLinux64');
expect(Cli.options!).not.toHaveProperty('unityVersion');
expect(Cli.options!).not.toHaveProperty('buildName');
});
});
describe('boolean conversion', () => {
it('converts boolean true to string "true"', () => {
const cliArguments: CliArguments = { manualExit: true };
mapCliArgumentsToInput(cliArguments);
expect(Cli.options!['manualExit']).toStrictEqual('true');
});
it('converts boolean false to string "false"', () => {
const cliArguments: CliArguments = { enableGpu: false };
mapCliArgumentsToInput(cliArguments);
expect(Cli.options!['enableGpu']).toStrictEqual('false');
});
it('converts allowDirtyBuild boolean to string', () => {
const cliArguments: CliArguments = { allowDirtyBuild: true };
mapCliArgumentsToInput(cliArguments);
expect(Cli.options!['allowDirtyBuild']).toStrictEqual('true');
});
});
describe('yargs internal properties', () => {
it('filters out yargs _ property', () => {
const cliArguments: CliArguments = {
targetPlatform: 'iOS',
_: ['build'] as any,
};
mapCliArgumentsToInput(cliArguments);
expect(Cli.options!).not.toHaveProperty('_');
});
it('filters out yargs $0 property', () => {
const cliArguments: CliArguments = {
targetPlatform: 'iOS',
$0: 'game-ci' as any,
};
mapCliArgumentsToInput(cliArguments);
expect(Cli.options!).not.toHaveProperty('$0');
});
});
describe('flag name conversion', () => {
it('passes camelCase keys through directly', () => {
const cliArguments: CliArguments = {
androidKeystoreName: 'my.keystore',
androidKeystorePass: 'secret',
dockerCpuLimit: '4',
dockerMemoryLimit: '8g',
};
mapCliArgumentsToInput(cliArguments);
expect(Cli.options!['androidKeystoreName']).toStrictEqual('my.keystore');
expect(Cli.options!['androidKeystorePass']).toStrictEqual('secret');
expect(Cli.options!['dockerCpuLimit']).toStrictEqual('4');
expect(Cli.options!['dockerMemoryLimit']).toStrictEqual('8g');
});
it('maps all android-related arguments', () => {
const cliArguments: CliArguments = {
androidVersionCode: '42',
androidExportType: 'androidAppBundle',
androidKeystoreBase64: 'base64data',
androidKeyaliasName: 'myalias',
androidKeyaliasPass: 'aliaspass',
androidTargetSdkVersion: '33',
androidSymbolType: 'public',
};
mapCliArgumentsToInput(cliArguments);
expect(Cli.options!['androidVersionCode']).toStrictEqual('42');
expect(Cli.options!['androidExportType']).toStrictEqual('androidAppBundle');
expect(Cli.options!['androidKeystoreBase64']).toStrictEqual('base64data');
expect(Cli.options!['androidKeyaliasName']).toStrictEqual('myalias');
expect(Cli.options!['androidKeyaliasPass']).toStrictEqual('aliaspass');
expect(Cli.options!['androidTargetSdkVersion']).toStrictEqual('33');
expect(Cli.options!['androidSymbolType']).toStrictEqual('public');
});
it('maps docker and container arguments', () => {
const cliArguments: CliArguments = {
dockerIsolationMode: 'hyperv',
dockerWorkspacePath: '/custom/workspace',
containerRegistryRepository: 'custom/editor',
containerRegistryImageVersion: '5',
runAsHostUser: 'true',
chownFilesTo: 'root:root',
};
mapCliArgumentsToInput(cliArguments);
expect(Cli.options!['dockerIsolationMode']).toStrictEqual('hyperv');
expect(Cli.options!['dockerWorkspacePath']).toStrictEqual('/custom/workspace');
expect(Cli.options!['containerRegistryRepository']).toStrictEqual('custom/editor');
expect(Cli.options!['containerRegistryImageVersion']).toStrictEqual('5');
expect(Cli.options!['runAsHostUser']).toStrictEqual('true');
expect(Cli.options!['chownFilesTo']).toStrictEqual('root:root');
});
it('maps orchestrator-related arguments', () => {
const cliArguments: CliArguments = {
providerStrategy: 'k8s',
awsStackName: 'my-stack',
kubeConfig: 'base64config',
kubeVolume: 'my-pvc',
kubeVolumeSize: '10Gi',
kubeStorageClass: 'gp3',
containerCpu: '2048',
containerMemory: '4096',
cacheKey: 'my-cache',
watchToEnd: 'false',
cloneDepth: '100',
};
mapCliArgumentsToInput(cliArguments);
expect(Cli.options!['providerStrategy']).toStrictEqual('k8s');
expect(Cli.options!['awsStackName']).toStrictEqual('my-stack');
expect(Cli.options!['kubeConfig']).toStrictEqual('base64config');
expect(Cli.options!['kubeVolume']).toStrictEqual('my-pvc');
expect(Cli.options!['kubeVolumeSize']).toStrictEqual('10Gi');
expect(Cli.options!['kubeStorageClass']).toStrictEqual('gp3');
expect(Cli.options!['containerCpu']).toStrictEqual('2048');
expect(Cli.options!['containerMemory']).toStrictEqual('4096');
expect(Cli.options!['cacheKey']).toStrictEqual('my-cache');
expect(Cli.options!['watchToEnd']).toStrictEqual('false');
expect(Cli.options!['cloneDepth']).toStrictEqual('100');
});
});
describe('Cli.isCliMode integration', () => {
it('enables CLI mode after mapping', () => {
const cliArguments: CliArguments = { targetPlatform: 'WebGL' };
mapCliArgumentsToInput(cliArguments);
expect(Cli.isCliMode).toStrictEqual(true);
});
it('is not in CLI mode before mapping', () => {
expect(Cli.isCliMode).toStrictEqual(false);
});
});
});
-83
View File
@@ -1,83 +0,0 @@
import type { CommandModule } from 'yargs';
import * as core from '@actions/core';
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
interface ActivateArguments extends CliArguments {
unityVersion?: string;
unitySerial?: string;
unityLicensingServer?: string;
}
const activateCommand: CommandModule<object, ActivateArguments> = {
command: 'activate',
describe: 'Verify Unity license configuration',
builder: (yargs) => {
return yargs
.option('unity-version', {
alias: 'unityVersion',
type: 'string',
description: 'Version of Unity to activate',
default: 'auto',
})
.option('unity-licensing-server', {
alias: 'unityLicensingServer',
type: 'string',
description: 'The Unity licensing server address for floating licenses',
default: '',
})
.env('UNITY')
.example(
'UNITY_SERIAL=XXXX-XXXX-XXXX-XXXX game-ci activate',
'Activate Unity using a serial from environment variable',
)
.example(
'game-ci activate --unity-licensing-server http://license-server:8080',
'Activate Unity using a floating license server',
) as any;
},
handler: async (cliArguments) => {
try {
mapCliArgumentsToInput(cliArguments);
const unitySerial = process.env.UNITY_SERIAL;
const unityLicense = process.env.UNITY_LICENSE;
const licensingServer = cliArguments.unityLicensingServer || process.env.UNITY_LICENSING_SERVER || '';
if (licensingServer) {
core.info(`Activating Unity via licensing server: ${licensingServer}`);
core.info('Floating license activation is handled automatically during builds.');
core.info('No manual activation step is needed when using a licensing server.');
return;
}
if (!unitySerial && !unityLicense) {
throw new Error(
'No Unity license found.\n\n' +
'Provide one of the following:\n' +
' - UNITY_SERIAL environment variable (professional license)\n' +
' - UNITY_LICENSE environment variable (personal license file content)\n' +
' - --unity-licensing-server flag (floating license)\n\n' +
'For more information, visit: https://game.ci/docs/github/activation',
);
}
if (unitySerial) {
const maskedSerial = unitySerial.length > 8 ? `${unitySerial.slice(0, 4)}...${unitySerial.slice(-4)}` : '****';
core.info(`Unity serial detected: ${maskedSerial}`);
core.info('License will be activated automatically when running a build.');
} else if (unityLicense) {
core.info('Unity license file detected from UNITY_LICENSE environment variable.');
core.info('License will be activated automatically when running a build.');
}
core.info('\nActivation verified. You can now run: game-ci build --target-platform <platform>');
} catch (error: any) {
core.setFailed(`Activation failed: ${error.message}`);
throw error;
}
},
};
export default activateCommand;
-299
View File
@@ -1,299 +0,0 @@
import type { CommandModule } from 'yargs';
import * as core from '@actions/core';
import { BuildParameters, ImageTag, Orchestrator } from '../../model';
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
import MacBuilder from '../../model/mac-builder';
import Docker from '../../model/docker';
import Action from '../../model/action';
import PlatformSetup from '../../model/platform-setup';
interface BuildArguments extends CliArguments {
targetPlatform: string;
}
const buildCommand: CommandModule<object, BuildArguments> = {
command: 'build',
describe: 'Build a Unity project',
builder: (yargs) => {
return yargs
.option('target-platform', {
alias: 'targetPlatform',
type: 'string',
description: 'Platform that the build should target',
demandOption: true,
})
.option('unity-version', {
alias: 'unityVersion',
type: 'string',
description: 'Version of Unity to use for building the project. Use "auto" to detect.',
default: 'auto',
})
.option('project-path', {
alias: 'projectPath',
type: 'string',
description: 'Path to the Unity project to be built',
default: '.',
})
.option('build-profile', {
alias: 'buildProfile',
type: 'string',
description: 'Path to the build profile to activate, relative to the project root',
default: '',
})
.option('build-name', {
alias: 'buildName',
type: 'string',
description: 'Name of the build (no file extension)',
default: '',
})
.option('builds-path', {
alias: 'buildsPath',
type: 'string',
description: 'Path where the builds should be stored',
default: 'build',
})
.option('build-method', {
alias: 'buildMethod',
type: 'string',
description: 'Path to a Namespace.Class.StaticMethod to run to perform the build',
default: '',
})
.option('custom-parameters', {
alias: 'customParameters',
type: 'string',
description: 'Custom parameters to configure the build',
default: '',
})
.option('versioning', {
type: 'string',
description: 'The versioning scheme to use when building the project',
default: 'Semantic',
})
.option('version', {
type: 'string',
description: 'The version, when used with the "Custom" versioning scheme',
default: '',
})
.option('custom-image', {
alias: 'customImage',
type: 'string',
description: 'Specific docker image that should be used for building the project',
default: '',
})
.option('manual-exit', {
alias: 'manualExit',
type: 'boolean',
description: 'Suppresses -quit. Exit your build method using EditorApplication.Exit(0) instead.',
default: false,
})
.option('enable-gpu', {
alias: 'enableGpu',
type: 'boolean',
description: 'Launches unity without specifying -nographics',
default: false,
})
.option('android-version-code', {
alias: 'androidVersionCode',
type: 'string',
description: 'The android versionCode',
default: '',
})
.option('android-export-type', {
alias: 'androidExportType',
type: 'string',
description: 'The android export type (androidPackage, androidAppBundle, androidStudioProject)',
default: 'androidPackage',
})
.option('android-keystore-name', {
alias: 'androidKeystoreName',
type: 'string',
description: 'The android keystoreName',
default: '',
})
.option('android-keystore-base64', {
alias: 'androidKeystoreBase64',
type: 'string',
description: 'The base64 contents of the android keystore file',
default: '',
})
.option('android-keystore-pass', {
alias: 'androidKeystorePass',
type: 'string',
description: 'The android keystorePass',
default: '',
})
.option('android-keyalias-name', {
alias: 'androidKeyaliasName',
type: 'string',
description: 'The android keyaliasName',
default: '',
})
.option('android-keyalias-pass', {
alias: 'androidKeyaliasPass',
type: 'string',
description: 'The android keyaliasPass',
default: '',
})
.option('android-target-sdk-version', {
alias: 'androidTargetSdkVersion',
type: 'string',
description: 'The android target API level',
default: '',
})
.option('android-symbol-type', {
alias: 'androidSymbolType',
type: 'string',
description: 'The android symbol type to export (none, public, debugging)',
default: 'none',
})
.option('docker-cpu-limit', {
alias: 'dockerCpuLimit',
type: 'string',
description: 'Number of CPU cores to assign the docker container',
default: '',
})
.option('docker-memory-limit', {
alias: 'dockerMemoryLimit',
type: 'string',
description: 'Amount of memory to assign the docker container (e.g. 512m, 4g)',
default: '',
})
.option('docker-workspace-path', {
alias: 'dockerWorkspacePath',
type: 'string',
description: 'The path to mount the workspace inside the docker container',
default: '/github/workspace',
})
.option('run-as-host-user', {
alias: 'runAsHostUser',
type: 'string',
description: 'Whether to run as a user that matches the host system',
default: 'false',
})
.option('chown-files-to', {
alias: 'chownFilesTo',
type: 'string',
description: 'User and optionally group to give ownership of build artifacts',
default: '',
})
.option('ssh-agent', {
alias: 'sshAgent',
type: 'string',
description: 'SSH Agent path to forward to the container',
default: '',
})
.option('git-private-token', {
alias: 'gitPrivateToken',
type: 'string',
description: 'GitHub private token to pull from GitHub',
default: '',
})
.option('provider-strategy', {
alias: 'providerStrategy',
type: 'string',
description: 'Execution strategy: local, k8s, or aws',
default: 'local',
})
.option('skip-activation', {
alias: 'skipActivation',
type: 'string',
description: 'Skip the activation/deactivation of Unity',
default: 'false',
})
.option('unity-licensing-server', {
alias: 'unityLicensingServer',
type: 'string',
description: 'The Unity licensing server address',
default: '',
})
.option('container-registry-repository', {
alias: 'containerRegistryRepository',
type: 'string',
description: 'Container registry and repository to pull image from. Only applicable if customImage is not set.',
default: 'unityci/editor',
})
.option('container-registry-image-version', {
alias: 'containerRegistryImageVersion',
type: 'string',
description: 'Container registry image version. Only applicable if customImage is not set.',
default: '3',
})
.option('docker-isolation-mode', {
alias: 'dockerIsolationMode',
type: 'string',
description:
'Isolation mode to use for the docker container (process, hyperv, or default). Only applicable on Windows.',
default: 'default',
})
.option('ssh-public-keys-directory-path', {
alias: 'sshPublicKeysDirectoryPath',
type: 'string',
description: 'Path to a directory containing SSH public keys to forward to the container',
default: '',
})
.option('cache-unity-installation-on-mac', {
alias: 'cacheUnityInstallationOnMac',
type: 'boolean',
description: 'Whether to cache the Unity hub and editor installation on MacOS',
default: false,
})
.option('unity-hub-version-on-mac', {
alias: 'unityHubVersionOnMac',
type: 'string',
description: 'The version of Unity Hub to install on MacOS (e.g. 3.4.0). Defaults to latest available on brew.',
default: '',
})
.example('game-ci build --target-platform StandaloneLinux64', 'Build for Linux using auto-detected Unity version')
.example(
'game-ci build --target-platform Android --unity-version 2022.3.56f1 --build-method MyBuild.Run',
'Build for Android with a specific Unity version and build method',
) as any;
},
handler: async (cliArguments) => {
try {
mapCliArgumentsToInput(cliArguments);
const buildParameters = await BuildParameters.create();
const baseImage = new ImageTag(buildParameters);
let exitCode = -1;
if (buildParameters.providerStrategy === 'local') {
core.info(`Building locally for ${buildParameters.targetPlatform}...`);
core.info(`Unity version: ${buildParameters.editorVersion}`);
core.info(`Project path: ${buildParameters.projectPath}`);
const actionFolder = Action.actionFolder;
await PlatformSetup.setup(buildParameters, actionFolder);
exitCode =
process.platform === 'darwin'
? await MacBuilder.run(actionFolder)
: await Docker.run(baseImage.toString(), {
workspace: process.cwd(),
actionFolder,
...buildParameters,
});
} else {
core.info(`Building via orchestrator (${buildParameters.providerStrategy})...`);
await Orchestrator.run(buildParameters, baseImage.toString());
exitCode = 0;
}
// Output results
core.info(`\nBuild completed with exit code: ${exitCode}`);
core.info(`Build version: ${buildParameters.buildVersion}`);
core.info(`Build path: ${buildParameters.buildPath}`);
if (exitCode !== 0) {
throw new Error(`Build failed with exit code ${exitCode}`);
}
} catch (error: any) {
core.setFailed(`Build failed: ${error.message}`);
throw error;
}
},
};
export default buildCommand;
-160
View File
@@ -1,160 +0,0 @@
import type { CommandModule } from 'yargs';
import * as core from '@actions/core';
import fs from 'node:fs';
import path from 'node:path';
const cacheCommand: CommandModule = {
command: 'cache <action>',
describe: 'Manage build caches',
builder: (yargs) => {
return yargs
.positional('action', {
describe: 'Cache action to perform',
choices: ['list', 'restore', 'clear'] as const,
})
.option('cache-dir', {
alias: 'cacheDir',
type: 'string',
description: 'Path to the cache directory',
default: '',
})
.option('project-path', {
alias: 'projectPath',
type: 'string',
description: 'Path to the Unity project',
default: '.',
})
.example('game-ci orchestrate cache list', 'List all cached workspaces')
.example('game-ci orchestrate cache restore --cache-dir ./my-cache', 'Restore a cached workspace')
.example('game-ci orchestrate cache clear', 'Clear all cached workspaces');
},
handler: async (cliArguments) => {
const action = cliArguments.action as string;
const projectPath = (cliArguments.projectPath as string) || '.';
const cacheDirectory = (cliArguments.cacheDir as string) || path.join(projectPath, 'Library');
try {
switch (action) {
case 'list': {
await listCache(cacheDirectory, projectPath);
break;
}
case 'restore': {
await restoreCache(cacheDirectory);
break;
}
case 'clear': {
await clearCache(cacheDirectory);
break;
}
default: {
throw new Error(`Unknown cache action: ${action}. Available actions: list, restore, clear`);
}
}
} catch (error: any) {
core.setFailed(`Cache operation failed: ${error.message}`);
throw error;
}
},
};
async function listCache(cacheDirectory: string, projectPath: string): Promise<void> {
const libraryPath = path.resolve(projectPath, 'Library');
core.info('Cache Status:');
core.info('=============');
if (fs.existsSync(libraryPath)) {
const stats = fs.statSync(libraryPath);
const files = fs.readdirSync(libraryPath);
core.info(` Library folder: ${libraryPath}`);
core.info(` Entries: ${files.length}`);
core.info(` Last modified: ${stats.mtime.toISOString()}`);
// Show size of key subdirectories
const keyDirectories = ['PackageCache', 'ScriptAssemblies', 'ShaderCache', 'Bee'];
for (const directory of keyDirectories) {
const directoryPath = path.join(libraryPath, directory);
if (fs.existsSync(directoryPath)) {
const directoryStats = fs.statSync(directoryPath);
core.info(` ${directory}/: exists (modified ${directoryStats.mtime.toISOString()})`);
}
}
} else {
core.info(` Library folder not found at: ${libraryPath}`);
core.info(' No cache available. First build will be a clean build.');
}
// Check for .tar cache files if a custom cache dir is specified
if (cacheDirectory && cacheDirectory !== libraryPath && fs.existsSync(cacheDirectory)) {
core.info(`\nCache directory: ${cacheDirectory}`);
const cacheFiles = fs.readdirSync(cacheDirectory).filter((f) => f.endsWith('.tar') || f.endsWith('.tar.lz4'));
if (cacheFiles.length > 0) {
core.info(` Cache archives found: ${cacheFiles.length}`);
for (const file of cacheFiles) {
const filePath = path.join(cacheDirectory, file);
const fileStats = fs.statSync(filePath);
const sizeMegabytes = (fileStats.size / (1024 * 1024)).toFixed(1);
core.info(` - ${file} (${sizeMegabytes} MB, ${fileStats.mtime.toISOString()})`);
}
} else {
core.info(' No cache archives found.');
}
}
}
async function restoreCache(cacheDirectory: string): Promise<void> {
if (!cacheDirectory) {
throw new Error('--cache-dir is required for restore');
}
if (!fs.existsSync(cacheDirectory)) {
core.info(`Cache directory does not exist: ${cacheDirectory}`);
core.info('Nothing to restore.');
return;
}
const cacheFiles = fs.readdirSync(cacheDirectory).filter((f) => f.endsWith('.tar') || f.endsWith('.tar.lz4'));
if (cacheFiles.length === 0) {
core.info('No cache archives found to restore.');
return;
}
// Sort by modification time, newest first
const sorted = cacheFiles
.map((f) => ({ name: f, mtime: fs.statSync(path.join(cacheDirectory, f)).mtime }))
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
core.info(`Found ${sorted.length} cache archive(s). Latest: ${sorted[0].name}`);
core.info('Use the orchestrator cache system for full restore functionality:');
core.info(' game-ci orchestrate --cache-key <key> ...');
}
async function clearCache(cacheDirectory: string): Promise<void> {
let cleared = false;
if (cacheDirectory && fs.existsSync(cacheDirectory)) {
const cacheFiles = fs.readdirSync(cacheDirectory).filter((f) => f.endsWith('.tar') || f.endsWith('.tar.lz4'));
if (cacheFiles.length > 0) {
for (const file of cacheFiles) {
fs.unlinkSync(path.join(cacheDirectory, file));
core.info(` Removed: ${file}`);
}
cleared = true;
}
}
if (!cleared) {
core.info('No cache archives found to clear.');
} else {
core.info('Cache cleared.');
}
}
export default cacheCommand;
-222
View File
@@ -1,222 +0,0 @@
import type { CommandModule } from 'yargs';
import * as core from '@actions/core';
import { BuildParameters, ImageTag, Orchestrator } from '../../model';
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
import cacheCommand from './cache';
interface OrchestrateArguments extends CliArguments {
targetPlatform: string;
providerStrategy?: string;
}
const orchestrateCommand: CommandModule<object, OrchestrateArguments> = {
command: 'orchestrate',
describe: 'Orchestrator — remote builds, cache management, and provider tools',
builder: (yargs) => {
return yargs
.command(cacheCommand)
.option('target-platform', {
alias: 'targetPlatform',
type: 'string',
description: 'Platform that the build should target',
})
.option('provider-strategy', {
alias: 'providerStrategy',
type: 'string',
description: 'Orchestrator provider: aws, k8s, local-docker, local-system',
default: 'aws',
})
.option('unity-version', {
alias: 'unityVersion',
type: 'string',
description: 'Version of Unity to use for building',
default: 'auto',
})
.option('project-path', {
alias: 'projectPath',
type: 'string',
description: 'Path to the Unity project to be built',
default: '.',
})
.option('build-name', {
alias: 'buildName',
type: 'string',
description: 'Name of the build',
default: '',
})
.option('builds-path', {
alias: 'buildsPath',
type: 'string',
description: 'Path where the builds should be stored',
default: 'build',
})
.option('build-method', {
alias: 'buildMethod',
type: 'string',
description: 'Path to a Namespace.Class.StaticMethod to run to perform the build',
default: '',
})
.option('custom-parameters', {
alias: 'customParameters',
type: 'string',
description: 'Custom parameters to configure the build',
default: '',
})
.option('versioning', {
type: 'string',
description: 'The versioning scheme to use',
default: 'None',
})
.option('aws-stack-name', {
alias: 'awsStackName',
type: 'string',
description: 'The Cloud Formation stack name (AWS provider)',
default: 'game-ci',
})
.option('kube-config', {
alias: 'kubeConfig',
type: 'string',
description: 'Base64 encoded Kubernetes config (K8s provider)',
default: '',
})
.option('kube-volume', {
alias: 'kubeVolume',
type: 'string',
description: 'Persistent Volume Claim name for Unity build (K8s provider)',
default: '',
})
.option('kube-volume-size', {
alias: 'kubeVolumeSize',
type: 'string',
description: 'Disc space for Kubernetes Persistent Volume',
default: '5Gi',
})
.option('container-cpu', {
alias: 'containerCpu',
type: 'string',
description: 'CPU allocation for remote build container',
default: '1024',
})
.option('container-memory', {
alias: 'containerMemory',
type: 'string',
description: 'Memory allocation for remote build container',
default: '3072',
})
.option('cache-key', {
alias: 'cacheKey',
type: 'string',
description: 'Cache key to indicate bucket for cache',
default: '',
})
.option('git-private-token', {
alias: 'gitPrivateToken',
type: 'string',
description: 'GitHub private token for repository access',
default: '',
})
.option('allow-dirty-build', {
alias: 'allowDirtyBuild',
type: 'boolean',
description: 'Allow builds from dirty branches',
default: false,
})
.option('watch-to-end', {
alias: 'watchToEnd',
type: 'string',
description: 'Whether to watch the build to completion',
default: 'true',
})
.option('clone-depth', {
alias: 'cloneDepth',
type: 'string',
description: 'Git clone depth (0 for full clone)',
default: '50',
})
.option('skip-activation', {
alias: 'skipActivation',
type: 'string',
description: 'Skip Unity activation/deactivation',
default: 'false',
})
.option('kube-storage-class', {
alias: 'kubeStorageClass',
type: 'string',
description: 'Kubernetes storage class to use for orchestrator jobs. Leave empty to install rook cluster.',
default: '',
})
.option('read-input-from-override-list', {
alias: 'readInputFromOverrideList',
type: 'string',
description: 'Comma separated list of input value names to read from the input override command',
default: '',
})
.option('read-input-override-command', {
alias: 'readInputOverrideCommand',
type: 'string',
description: 'Command to execute to pull input from an external source (e.g. cloud provider secret managers)',
default: '',
})
.option('post-build-steps', {
alias: 'postBuildSteps',
type: 'string',
description:
'Post build job in yaml format with the keys image, secrets (name, value object array), command string',
default: '',
})
.option('pre-build-steps', {
alias: 'preBuildSteps',
type: 'string',
description:
'Pre build job after repository setup but before the build job (yaml format with keys image, secrets, command)',
default: '',
})
.option('custom-job', {
alias: 'customJob',
type: 'string',
description:
'Custom job instead of the standard build automation (yaml format with keys image, secrets, command)',
default: '',
})
.example(
'game-ci orchestrate --target-platform StandaloneLinux64 --provider-strategy aws',
'Build on AWS using the orchestrator',
)
.example(
'game-ci orchestrate --target-platform StandaloneLinux64 --provider-strategy k8s --kube-config <base64>',
'Build on Kubernetes',
) as any;
},
handler: async (cliArguments) => {
try {
if (!cliArguments.targetPlatform) {
throw new Error('--target-platform is required for orchestrate builds. Run game-ci orchestrate --help.');
}
mapCliArgumentsToInput(cliArguments);
const buildParameters = await BuildParameters.create();
const baseImage = new ImageTag(buildParameters);
core.info(`Orchestrating build via ${buildParameters.providerStrategy}...`);
core.info(`Target platform: ${buildParameters.targetPlatform}`);
core.info(`Unity version: ${buildParameters.editorVersion}`);
core.info(`Build GUID: ${buildParameters.buildGuid}`);
const result = await Orchestrator.run(buildParameters, baseImage.toString());
core.info(`\nOrchestrated build completed.`);
if (result?.BuildResults) {
core.info(`Results: ${result.BuildResults}`);
} else {
core.warning('Build completed but no build results were returned.');
}
} catch (error: any) {
core.setFailed(`Orchestrated build failed: ${error.message}`);
throw error;
}
},
};
export default orchestrateCommand;
-84
View File
@@ -1,84 +0,0 @@
import type { CommandModule } from 'yargs';
import * as core from '@actions/core';
import fs from 'node:fs';
import path from 'node:path';
import UnityVersioning from '../../model/unity-versioning';
const statusCommand: CommandModule = {
command: 'status',
describe: 'Show build status and workspace info',
builder: (yargs) => {
return yargs.option('project-path', {
alias: 'projectPath',
type: 'string',
description: 'Path to the Unity project',
default: '.',
});
},
handler: async (cliArguments) => {
const projectPath = (cliArguments.projectPath as string) || '.';
core.info('game-ci Workspace Status');
core.info('========================\n');
// Project detection
const projectVersionPath = path.join(projectPath, 'ProjectSettings', 'ProjectVersion.txt');
const hasProject = fs.existsSync(projectVersionPath);
core.info(`Project Path: ${path.resolve(projectPath)}`);
core.info(`Unity Project Found: ${hasProject ? 'Yes' : 'No'}`);
if (hasProject) {
try {
const unityVersion = UnityVersioning.determineUnityVersion(projectPath, 'auto');
core.info(`Unity Version: ${unityVersion}`);
} catch {
core.info(`Unity Version: Unable to detect`);
}
// Library folder status
const libraryPath = path.join(projectPath, 'Library');
if (fs.existsSync(libraryPath)) {
const stats = fs.statSync(libraryPath);
core.info(`Library Cache: Present (modified ${stats.mtime.toISOString()})`);
} else {
core.info(`Library Cache: Not present (clean build required)`);
}
// Build output detection
const buildsPath = path.join(projectPath, '..', 'build');
if (fs.existsSync(buildsPath)) {
const builds = fs.readdirSync(buildsPath);
if (builds.length > 0) {
core.info(`\nBuild Outputs (${buildsPath}):`);
for (const build of builds) {
const buildPath = path.join(buildsPath, build);
const buildStats = fs.statSync(buildPath);
core.info(` - ${build} (${buildStats.isDirectory() ? 'dir' : 'file'}, ${buildStats.mtime.toISOString()})`);
}
}
}
}
// Environment
core.info('\nEnvironment:');
core.info(` Platform: ${process.platform}`);
core.info(` Node.js: ${process.version}`);
core.info(` UNITY_SERIAL: ${process.env.UNITY_SERIAL ? 'Set' : 'Not set'}`);
core.info(` UNITY_LICENSE: ${process.env.UNITY_LICENSE ? 'Set' : 'Not set'}`);
core.info(` UNITY_EMAIL: ${process.env.UNITY_EMAIL ? 'Set' : 'Not set'}`);
core.info(` UNITY_PASSWORD: ${process.env.UNITY_PASSWORD ? 'Set' : 'Not set'}`);
// Docker availability
core.info(`\nDocker: Checking...`);
try {
const { execSync } = await import('node:child_process');
const dockerVersion = execSync('docker --version', { encoding: 'utf8' }).trim();
core.info(` ${dockerVersion}`);
} catch {
core.info(` Docker not found or not accessible`);
}
},
};
export default statusCommand;
-387
View File
@@ -1,387 +0,0 @@
import type { CommandModule } from 'yargs';
import * as core from '@actions/core';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import https from 'node:https';
import http from 'node:http';
import { execFileSync } from 'node:child_process';
const REPO = 'game-ci/unity-builder';
interface GitHubRelease {
// eslint-disable-next-line camelcase
tag_name: string;
assets: Array<{
name: string;
// eslint-disable-next-line camelcase
browser_download_url: string;
size: number;
}>;
}
interface UpdateArguments {
force?: boolean;
version?: string;
}
/**
* Fetches JSON from a URL via HTTPS, following redirects.
*/
function fetchJson(url: string): Promise<any> {
return new Promise((resolve, reject) => {
const get = (targetUrl: string, redirectCount: number) => {
if (redirectCount > 5) {
reject(new Error('Too many redirects'));
return;
}
https
.get(
targetUrl,
{
headers: { 'User-Agent': 'game-ci-cli', Accept: 'application/json' },
},
(response) => {
if (
response.statusCode &&
response.statusCode >= 300 &&
response.statusCode < 400 &&
response.headers.location
) {
get(response.headers.location, redirectCount + 1);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode} from ${targetUrl}`));
return;
}
let data = '';
response.on('data', (chunk) => (data += chunk));
response.on('end', () => {
try {
resolve(JSON.parse(data));
} catch {
reject(new Error('Invalid JSON response'));
}
});
},
)
.on('error', reject);
};
get(url, 0);
});
}
/**
* Downloads a file from a URL, following redirects. Returns the file content as a Buffer.
*/
function downloadFile(url: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const get = (targetUrl: string, redirectCount: number) => {
if (redirectCount > 10) {
reject(new Error('Too many redirects'));
return;
}
const protocol = targetUrl.startsWith('https') ? https : http;
protocol
.get(targetUrl, { headers: { 'User-Agent': 'game-ci-cli' } }, (response) => {
if (
response.statusCode &&
response.statusCode >= 300 &&
response.statusCode < 400 &&
response.headers.location
) {
get(response.headers.location, redirectCount + 1);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode} downloading ${targetUrl}`));
return;
}
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => resolve(Buffer.concat(chunks)));
})
.on('error', reject);
};
get(url, 0);
});
}
/**
* Gets the current version from package.json or the compiled binary.
*/
function getCurrentVersion(): string {
// Try reading from package.json at various relative locations
const candidates = [
path.join(__dirname, '..', '..', '..', 'package.json'),
path.join(__dirname, '..', '..', 'package.json'),
path.join(process.cwd(), 'package.json'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
try {
const packageData = JSON.parse(fs.readFileSync(candidate, 'utf8'));
if (packageData.version) {
return packageData.version;
}
} catch {
// Continue to next candidate
}
}
}
return 'unknown';
}
/**
* Determines the correct asset name for the current platform/architecture.
*/
function getAssetName(): string {
const platform = process.platform;
const arch = process.arch;
let osPart: string;
switch (platform) {
case 'linux':
osPart = 'linux';
break;
case 'darwin':
osPart = 'macos';
break;
case 'win32':
osPart = 'windows';
break;
default:
throw new Error(`Unsupported platform: ${platform}`);
}
let archPart: string;
switch (arch) {
case 'x64':
archPart = 'x64';
break;
case 'arm64':
archPart = 'arm64';
break;
default:
throw new Error(`Unsupported architecture: ${arch}`);
}
const assetBaseName = `game-ci-${osPart}-${archPart}`;
return osPart === 'windows' ? `${assetBaseName}.exe` : assetBaseName;
}
/**
* Determines the path to the currently running executable.
* For standalone binaries (pkg), process.execPath points to the binary itself.
* For Node.js execution, we return undefined since self-update does not apply.
*/
function getExecutablePath(): string | undefined {
// When running as a pkg binary, process.pkg is defined
if ((process as any).pkg) {
return process.execPath;
}
// When running via Node.js, check if there is a standalone binary in the typical install location
const installDirectory = process.env.GAME_CI_INSTALL || path.join(os.homedir(), '.game-ci', 'bin');
const binaryName = process.platform === 'win32' ? 'game-ci.exe' : 'game-ci';
const installedPath = path.join(installDirectory, binaryName);
if (fs.existsSync(installedPath)) {
return installedPath;
}
return;
}
/**
* Strips leading 'v' from a version string and splits into numeric parts.
*/
function parseVersionParts(version: string): number[] {
return version
.replace(/^v/, '')
.split('.')
.map((part) => Number(part));
}
/**
* Compares two semver strings. Returns:
* -1 if a < b
* 0 if a == b
* 1 if a > b
*/
function compareSemver(a: string, b: string): number {
const partsA = parseVersionParts(a);
const partsB = parseVersionParts(b);
for (let index = 0; index < 3; index++) {
const x = partsA[index] || 0;
const y = partsB[index] || 0;
if (x < y) return -1;
if (x > y) return 1;
}
return 0;
}
const updateCommand: CommandModule<object, UpdateArguments> = {
command: 'update',
describe: 'Update game-ci to the latest version',
builder: (yargs) => {
return yargs
.option('force', {
alias: 'f',
type: 'boolean',
description: 'Force update even if already on latest version',
default: false,
})
.option('version', {
type: 'string',
description: 'Update to a specific version (e.g., v2.0.0)',
default: '',
})
.example('game-ci update', 'Update to the latest version')
.example('game-ci update --version v2.1.0', 'Update to a specific version')
.example('game-ci update --force', 'Force reinstall of the current version') as any;
},
handler: async (cliArguments) => {
try {
const currentVersion = getCurrentVersion();
core.info(`Current version: v${currentVersion}`);
core.info(`Platform: ${process.platform} ${process.arch}`);
core.info('');
// Fetch release info
let release: GitHubRelease;
const targetVersion = cliArguments.version as string;
if (targetVersion) {
const tag = targetVersion.startsWith('v') ? targetVersion : `v${targetVersion}`;
core.info(`Fetching release ${tag}...`);
release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`);
} else {
core.info('Checking for updates...');
release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
}
const latestVersion = release.tag_name;
core.info(`Latest version: ${latestVersion}`);
core.info('');
// Compare versions
const comparison = compareSemver(currentVersion, latestVersion);
if (comparison >= 0 && !cliArguments.force) {
core.info('You are already on the latest version. Use --force to reinstall.');
return;
}
if (comparison > 0 && !targetVersion) {
core.info(`Current version (v${currentVersion}) is newer than latest release (${latestVersion}).`);
core.info('Use --force to downgrade, or --version to target a specific release.');
return;
}
// Find the correct asset
const assetName = getAssetName();
const asset = release.assets.find((a) => a.name === assetName);
if (!asset) {
const available = release.assets.map((a) => a.name).join(', ');
throw new Error(
`No binary found for ${process.platform}-${process.arch} (looking for ${assetName}).\nAvailable assets: ${available}`,
);
}
const sizeMb = (asset.size / (1024 * 1024)).toFixed(1);
core.info(`Downloading ${assetName} (${sizeMb} MB)...`);
// Download the new binary
const binaryData = await downloadFile(asset.browser_download_url);
// Determine where to write the updated binary
const executablePath = getExecutablePath();
if (!executablePath) {
core.info('');
core.info('game-ci is running via Node.js (not as a standalone binary).');
core.info('To update the npm package, run:');
core.info(' npm install -g unity-builder@latest');
core.info('');
core.info('To install the standalone binary instead:');
core.info(' curl -fsSL https://raw.githubusercontent.com/game-ci/unity-builder/main/install.sh | sh');
return;
}
// Write the new binary.
// On Windows, we cannot overwrite a running executable directly.
// Write to a temporary file, then rename.
const temporaryPath = `${executablePath}.update`;
const backupPath = `${executablePath}.backup`;
fs.writeFileSync(temporaryPath, binaryData);
if (process.platform !== 'win32') {
fs.chmodSync(temporaryPath, 0o755);
}
// Verify the downloaded binary
try {
const output = execFileSync(temporaryPath, ['version'], { encoding: 'utf8', timeout: 10_000 });
core.info(`Verified new binary: ${output.trim().split('\n')[0]}`);
} catch (verifyError: any) {
fs.unlinkSync(temporaryPath);
throw new Error(`Downloaded binary failed verification: ${verifyError.message}`);
}
// Replace the current binary
try {
// Backup current
if (fs.existsSync(backupPath)) {
fs.unlinkSync(backupPath);
}
fs.renameSync(executablePath, backupPath);
fs.renameSync(temporaryPath, executablePath);
// Clean up backup
try {
fs.unlinkSync(backupPath);
} catch {
// On Windows the backup may be locked; that is fine
}
} catch (replaceError: any) {
// Attempt to restore from backup
if (fs.existsSync(backupPath) && !fs.existsSync(executablePath)) {
fs.renameSync(backupPath, executablePath);
}
// Clean up temporary file
if (fs.existsSync(temporaryPath)) {
fs.unlinkSync(temporaryPath);
}
throw new Error(`Failed to replace binary: ${replaceError.message}`);
}
core.info('');
core.info(`Successfully updated game-ci to ${latestVersion}`);
} catch (error: any) {
core.error(`Update failed: ${error.message}`);
throw error;
}
},
};
export default updateCommand;
-37
View File
@@ -1,37 +0,0 @@
import type { CommandModule } from 'yargs';
import * as core from '@actions/core';
import fs from 'node:fs';
import path from 'node:path';
const versionCommand: CommandModule = {
command: 'version',
describe: 'Show version info',
builder: {},
handler: async () => {
try {
// Read version from package.json
let packageJsonPath = path.join(__dirname, '..', '..', '..', 'package.json');
if (!fs.existsSync(packageJsonPath)) {
packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
}
if (!fs.existsSync(packageJsonPath)) {
packageJsonPath = path.join(process.cwd(), 'package.json');
}
if (fs.existsSync(packageJsonPath)) {
const packageData = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
core.info(`game-ci (unity-builder) v${packageData.version}`);
core.info(`Node.js ${process.version}`);
core.info(`Platform: ${process.platform} ${process.arch}`);
} else {
core.info('game-ci (unity-builder)');
core.info('Version information unavailable');
}
} catch (error: any) {
core.info('game-ci (unity-builder)');
core.error(`Could not read version: ${error.message}`);
}
},
};
export default versionCommand;
-106
View File
@@ -1,106 +0,0 @@
import { Cli } from '../model/cli/cli';
import GitHub from '../model/github';
/**
* Maps CLI arguments (kebab-case flags) to the Input/OrchestratorOptions
* interface used by the action. This bridges the gap between user-friendly
* CLI flags and the camelCase environment/input system unity-builder expects.
*
* The existing Input class already queries Cli.options, environment variables,
* and GitHub Action inputs in priority order. We populate Cli.options so that
* the rest of the codebase works unchanged.
*/
export interface CliArguments {
targetPlatform?: string;
unityVersion?: string;
projectPath?: string;
buildProfile?: string;
buildName?: string;
buildsPath?: string;
buildMethod?: string;
customParameters?: string;
versioning?: string;
version?: string;
customImage?: string;
manualExit?: boolean;
enableGpu?: boolean;
androidVersionCode?: string;
androidExportType?: string;
androidKeystoreName?: string;
androidKeystoreBase64?: string;
androidKeystorePass?: string;
androidKeyaliasName?: string;
androidKeyaliasPass?: string;
androidTargetSdkVersion?: string;
androidSymbolType?: string;
dockerCpuLimit?: string;
dockerMemoryLimit?: string;
dockerIsolationMode?: string;
dockerWorkspacePath?: string;
containerRegistryRepository?: string;
containerRegistryImageVersion?: string;
runAsHostUser?: string;
chownFilesTo?: string;
sshAgent?: string;
sshPublicKeysDirectoryPath?: string;
gitPrivateToken?: string;
providerStrategy?: string;
awsStackName?: string;
kubeConfig?: string;
kubeVolume?: string;
kubeVolumeSize?: string;
kubeStorageClass?: string;
containerCpu?: string;
containerMemory?: string;
cacheKey?: string;
watchToEnd?: string;
allowDirtyBuild?: boolean;
skipActivation?: string;
cloneDepth?: string;
readInputFromOverrideList?: string;
readInputOverrideCommand?: string;
postBuildSteps?: string;
preBuildSteps?: string;
customJob?: string;
unityLicensingServer?: string;
cacheUnityInstallationOnMac?: boolean;
unityHubVersionOnMac?: string;
mode?: string;
[key: string]: unknown;
}
/**
* Converts kebab-case CLI flags to camelCase keys matching the Input class
* property names, then injects them into Cli.options so the existing
* Input.getInput() / OrchestratorOptions.getInput() chain picks them up.
*/
export function mapCliArgumentsToInput(cliArguments: CliArguments): void {
// Disable GitHub Actions input reading when in CLI mode
GitHub.githubInputEnabled = false;
// The existing Cli.options mechanism is used by Input.getInput() to query
// CLI-provided values. We set it directly.
const mapped: Record<string, unknown> = {};
for (const [key, value] of Object.entries(cliArguments)) {
if (value !== undefined && key !== '_' && key !== '$0') {
mapped[key] = typeof value === 'boolean' ? String(value) : value;
}
}
// Ensure mode is set so Cli.isCliMode returns true
if (!mapped['mode']) {
mapped['mode'] = 'cli';
}
Cli.options = mapped;
}
-606
View File
@@ -1,606 +0,0 @@
/**
* Integration wiring tests for enterprise features in index.ts
*
* These tests verify the conditional gating logic in runMain():
* - Each enterprise feature is only invoked when its gate condition is met
* - Services are NOT called when their feature is disabled (the default)
* - The order of operations is correct (restore before build, save after build)
*/
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
import { BuildParameters } from './model';
// ---------------------------------------------------------------------------
// Service mocks — must be declared before importing index.ts (jest hoists them)
// ---------------------------------------------------------------------------
const mockChildWorkspaceService = {
buildConfig: jest.fn().mockReturnValue({ enabled: true, workspaceName: 'Test' }),
initializeWorkspace: jest.fn().mockReturnValue(false),
getWorkspaceSize: jest.fn().mockReturnValue('0 B'),
saveWorkspace: jest.fn(),
};
const mockSubmoduleProfileService = {
createInitPlan: jest.fn().mockResolvedValue([]),
execute: jest.fn().mockResolvedValue(''),
};
const mockLfsAgentService = {
configure: jest.fn().mockResolvedValue(''),
};
const mockLocalCacheService = {
resolveCacheRoot: jest.fn().mockReturnValue('/cache'),
generateCacheKey: jest.fn().mockReturnValue('key-1'),
restoreLfsCache: jest.fn().mockResolvedValue(true),
restoreLibraryCache: jest.fn().mockResolvedValue(true),
saveLibraryCache: jest.fn().mockResolvedValue(''),
saveLfsCache: jest.fn().mockResolvedValue(''),
};
const mockGitHooksService = {
installHooks: jest.fn().mockResolvedValue(''),
configureSkipList: jest.fn().mockReturnValue({ LEFTHOOK_EXCLUDE: 'pre-commit' }),
};
// Mock the dynamic import() targets — jest.mock with factory functions.
// The services are imported dynamically via `await import(...)` in index.ts,
// so we mock the module path and return the mock objects as named exports.
jest.mock('./model/orchestrator/services/cache/child-workspace-service', () => ({
ChildWorkspaceService: mockChildWorkspaceService,
}));
jest.mock('./model/orchestrator/services/submodule/submodule-profile-service', () => ({
SubmoduleProfileService: mockSubmoduleProfileService,
}));
jest.mock('./model/orchestrator/services/lfs/lfs-agent-service', () => ({
LfsAgentService: mockLfsAgentService,
}));
jest.mock('./model/orchestrator/services/cache/local-cache-service', () => ({
LocalCacheService: mockLocalCacheService,
}));
jest.mock('./model/orchestrator/services/hooks/git-hooks-service', () => ({
GitHooksService: mockGitHooksService,
}));
// Mock all non-enterprise dependencies to isolate the wiring logic
jest.mock('@actions/core');
jest.mock('./model', () => ({
Action: {
checkCompatibility: jest.fn(),
workspace: '/workspace',
actionFolder: '/action',
},
BuildParameters: {
create: jest.fn(),
},
Cache: {
verify: jest.fn(),
},
Orchestrator: {
run: jest.fn().mockResolvedValue(''),
},
Docker: {
run: jest.fn().mockResolvedValue(0),
},
ImageTag: jest.fn().mockImplementation(() => ({
toString: () => 'mock-image:latest',
})),
Output: {
setBuildVersion: jest.fn().mockResolvedValue(''),
setAndroidVersionCode: jest.fn().mockResolvedValue(''),
setEngineExitCode: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('./model/cli/cli', () => ({
Cli: {
InitCliMode: jest.fn().mockReturnValue(false),
},
}));
jest.mock('./model/mac-builder', () => ({
__esModule: true,
default: {
run: jest.fn().mockResolvedValue(0),
},
}));
jest.mock('./model/platform-setup', () => ({
__esModule: true,
default: {
setup: jest.fn().mockResolvedValue(''),
},
}));
const mockedBuildParametersCreate = BuildParameters.create as jest.Mock;
interface EnterpriseBuildParametersOverrides {
providerStrategy?: string;
childWorkspacesEnabled?: boolean;
childWorkspaceName?: string;
childWorkspaceCacheRoot?: string;
childWorkspacePreserveGit?: boolean;
childWorkspaceSeparateLibrary?: boolean;
submoduleProfilePath?: string;
submoduleVariantPath?: string;
submoduleToken?: string;
gitPrivateToken?: string;
lfsTransferAgent?: string;
lfsTransferAgentArgs?: string;
lfsStoragePaths?: string;
localCacheEnabled?: boolean;
localCacheRoot?: string;
localCacheLibrary?: boolean;
localCacheLfs?: boolean;
gitHooksEnabled?: boolean;
gitHooksSkipList?: string;
gitHooksRunBeforeBuild?: string;
}
function createMockBuildParameters(overrides: EnterpriseBuildParametersOverrides = {}) {
return {
// Required base properties
providerStrategy: 'local',
targetPlatform: 'StandaloneLinux64',
editorVersion: '2021.3.1f1',
buildVersion: '1.0.0',
androidVersionCode: '1',
projectPath: '.',
branch: 'main',
runnerTempPath: '/tmp',
// Enterprise features - all disabled by default
childWorkspacesEnabled: false,
childWorkspaceName: '',
childWorkspaceCacheRoot: '',
childWorkspacePreserveGit: true,
childWorkspaceSeparateLibrary: true,
submoduleProfilePath: '',
submoduleVariantPath: '',
submoduleToken: '',
gitPrivateToken: '',
lfsTransferAgent: '',
lfsTransferAgentArgs: '',
lfsStoragePaths: '',
localCacheEnabled: false,
localCacheRoot: '',
localCacheLibrary: true,
localCacheLfs: false,
gitHooksEnabled: false,
gitHooksSkipList: '',
gitHooksRunBeforeBuild: '',
...overrides,
};
}
/**
* The entry point (runMain) is invoked by importing index.ts.
* Since it calls `runMain()` at module scope, we need to re-import it
* for each test. jest.isolateModules() handles this.
*/
async function runIndex(overrides: EnterpriseBuildParametersOverrides = {}): Promise<void> {
mockedBuildParametersCreate.mockResolvedValue(createMockBuildParameters(overrides));
return new Promise<void>((resolve) => {
jest.isolateModules(() => {
require('./index');
// runMain() is async; give it a tick to complete
// We use setImmediate to ensure all microtasks from the dynamic imports resolve
});
// Allow all promises and microtasks to settle
setTimeout(resolve, 100);
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('index.ts enterprise feature wiring', () => {
const originalPlatform = process.platform;
const originalEnvironment = { ...process.env };
beforeEach(() => {
jest.clearAllMocks();
process.env.GITHUB_WORKSPACE = '/workspace';
// Force linux platform so Docker.run is used (not MacBuilder)
Object.defineProperty(process, 'platform', { value: 'linux' });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
process.env = { ...originalEnvironment };
});
// -----------------------------------------------------------------------
// GitHooksService gating
// -----------------------------------------------------------------------
describe('GitHooksService gating', () => {
it('should NOT call GitHooksService when gitHooksEnabled is false (default)', async () => {
await runIndex({ gitHooksEnabled: false });
expect(mockGitHooksService.installHooks).not.toHaveBeenCalled();
expect(mockGitHooksService.configureSkipList).not.toHaveBeenCalled();
});
it('should call installHooks when gitHooksEnabled is true', async () => {
await runIndex({ gitHooksEnabled: true });
expect(mockGitHooksService.installHooks).toHaveBeenCalledWith('/workspace');
});
it('should call configureSkipList when gitHooksEnabled and gitHooksSkipList is set', async () => {
await runIndex({
gitHooksEnabled: true,
gitHooksSkipList: 'pre-commit,pre-push',
});
expect(mockGitHooksService.configureSkipList).toHaveBeenCalledWith(['pre-commit', 'pre-push']);
});
it('should NOT call configureSkipList when gitHooksSkipList is empty', async () => {
await runIndex({
gitHooksEnabled: true,
gitHooksSkipList: '',
});
expect(mockGitHooksService.installHooks).toHaveBeenCalled();
expect(mockGitHooksService.configureSkipList).not.toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// LocalCacheService gating
// -----------------------------------------------------------------------
describe('LocalCacheService gating', () => {
it('should NOT call LocalCacheService when localCacheEnabled is false (default)', async () => {
await runIndex({ localCacheEnabled: false });
expect(mockLocalCacheService.resolveCacheRoot).not.toHaveBeenCalled();
expect(mockLocalCacheService.generateCacheKey).not.toHaveBeenCalled();
expect(mockLocalCacheService.restoreLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).not.toHaveBeenCalled();
});
it('should call restore and save operations when localCacheEnabled is true', async () => {
await runIndex({
localCacheEnabled: true,
localCacheLibrary: true,
localCacheLfs: true,
});
expect(mockLocalCacheService.resolveCacheRoot).toHaveBeenCalled();
expect(mockLocalCacheService.generateCacheKey).toHaveBeenCalled();
expect(mockLocalCacheService.restoreLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).toHaveBeenCalled();
});
it('should only cache Library when localCacheLibrary is true and localCacheLfs is false', async () => {
await runIndex({
localCacheEnabled: true,
localCacheLibrary: true,
localCacheLfs: false,
});
expect(mockLocalCacheService.restoreLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).not.toHaveBeenCalled();
});
it('should only cache LFS when localCacheLfs is true and localCacheLibrary is false', async () => {
await runIndex({
localCacheEnabled: true,
localCacheLibrary: false,
localCacheLfs: true,
});
expect(mockLocalCacheService.restoreLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// ChildWorkspaceService gating
// -----------------------------------------------------------------------
describe('ChildWorkspaceService gating', () => {
it('should NOT call ChildWorkspaceService when childWorkspacesEnabled is false (default)', async () => {
await runIndex({ childWorkspacesEnabled: false });
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
expect(mockChildWorkspaceService.initializeWorkspace).not.toHaveBeenCalled();
expect(mockChildWorkspaceService.saveWorkspace).not.toHaveBeenCalled();
});
it('should NOT call ChildWorkspaceService when childWorkspacesEnabled is true but childWorkspaceName is empty', async () => {
await runIndex({
childWorkspacesEnabled: true,
childWorkspaceName: '',
});
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
});
it('should call buildConfig, initializeWorkspace, and saveWorkspace when enabled with a name', async () => {
mockChildWorkspaceService.buildConfig.mockReturnValue({ enabled: true, workspaceName: 'TurnOfWar' });
await runIndex({
childWorkspacesEnabled: true,
childWorkspaceName: 'TurnOfWar',
childWorkspaceCacheRoot: '/cache/workspaces',
});
expect(mockChildWorkspaceService.buildConfig).toHaveBeenCalledWith(
expect.objectContaining({
childWorkspacesEnabled: true,
childWorkspaceName: 'TurnOfWar',
}),
);
expect(mockChildWorkspaceService.initializeWorkspace).toHaveBeenCalled();
expect(mockChildWorkspaceService.getWorkspaceSize).toHaveBeenCalled();
expect(mockChildWorkspaceService.saveWorkspace).toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// SubmoduleProfileService gating
// -----------------------------------------------------------------------
describe('SubmoduleProfileService gating', () => {
it('should NOT call SubmoduleProfileService when submoduleProfilePath is empty (default)', async () => {
await runIndex({ submoduleProfilePath: '' });
expect(mockSubmoduleProfileService.createInitPlan).not.toHaveBeenCalled();
expect(mockSubmoduleProfileService.execute).not.toHaveBeenCalled();
});
it('should call createInitPlan and execute when submoduleProfilePath is set', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleVariantPath: '',
submoduleToken: 'my-token',
});
expect(mockSubmoduleProfileService.createInitPlan).toHaveBeenCalledWith('/path/to/profile.yml', '', '/workspace');
expect(mockSubmoduleProfileService.execute).toHaveBeenCalled();
});
it('should pass variant path when provided', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleVariantPath: '/path/to/variant.yml',
});
expect(mockSubmoduleProfileService.createInitPlan).toHaveBeenCalledWith(
'/path/to/profile.yml',
'/path/to/variant.yml',
'/workspace',
);
});
it('should use submoduleToken for auth, falling back to gitPrivateToken', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleToken: '',
gitPrivateToken: 'fallback-token',
});
expect(mockSubmoduleProfileService.execute).toHaveBeenCalledWith(
expect.anything(),
'/workspace',
'fallback-token',
);
});
it('should prefer submoduleToken over gitPrivateToken', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleToken: 'specific-token',
gitPrivateToken: 'fallback-token',
});
expect(mockSubmoduleProfileService.execute).toHaveBeenCalledWith(
expect.anything(),
'/workspace',
'specific-token',
);
});
});
// -----------------------------------------------------------------------
// LfsAgentService gating
// -----------------------------------------------------------------------
describe('LfsAgentService gating', () => {
it('should NOT call LfsAgentService when lfsTransferAgent is empty (default)', async () => {
await runIndex({ lfsTransferAgent: '' });
expect(mockLfsAgentService.configure).not.toHaveBeenCalled();
});
it('should call configure when lfsTransferAgent is set', async () => {
await runIndex({
lfsTransferAgent: '/tools/elastic-git-storage',
lfsTransferAgentArgs: '--verbose',
lfsStoragePaths: '/path/a;/path/b',
});
expect(mockLfsAgentService.configure).toHaveBeenCalledWith(
'/tools/elastic-git-storage',
'--verbose',
['/path/a', '/path/b'],
'/workspace',
);
});
it('should pass empty array when lfsStoragePaths is empty', async () => {
await runIndex({
lfsTransferAgent: '/tools/agent',
lfsStoragePaths: '',
});
expect(mockLfsAgentService.configure).toHaveBeenCalledWith('/tools/agent', '', [], '/workspace');
});
});
// -----------------------------------------------------------------------
// Order of operations (restore before build, save after build)
// -----------------------------------------------------------------------
describe('order of operations', () => {
it('should execute restore operations before build and save operations after build', async () => {
const callOrder: string[] = [];
// Track call order for each relevant operation
mockChildWorkspaceService.buildConfig.mockReturnValue({ enabled: true, workspaceName: 'Test' });
mockChildWorkspaceService.initializeWorkspace.mockImplementation(() => {
callOrder.push('child-workspace-restore');
return false;
});
mockChildWorkspaceService.getWorkspaceSize.mockImplementation(() => {
callOrder.push('child-workspace-size');
return '0 B';
});
mockSubmoduleProfileService.createInitPlan.mockImplementation(async () => {
callOrder.push('submodule-profile-plan');
return [];
});
mockSubmoduleProfileService.execute.mockImplementation(async () => {
callOrder.push('submodule-profile-execute');
});
mockLfsAgentService.configure.mockImplementation(async () => {
callOrder.push('lfs-agent-configure');
});
mockLocalCacheService.resolveCacheRoot.mockImplementation(() => {
callOrder.push('local-cache-resolve');
return '/cache';
});
mockLocalCacheService.generateCacheKey.mockImplementation(() => {
callOrder.push('local-cache-keygen');
return 'key-1';
});
mockLocalCacheService.restoreLfsCache.mockImplementation(async () => {
callOrder.push('local-cache-restore-lfs');
return true;
});
mockLocalCacheService.restoreLibraryCache.mockImplementation(async () => {
callOrder.push('local-cache-restore-library');
return true;
});
mockGitHooksService.installHooks.mockImplementation(async () => {
callOrder.push('git-hooks-install');
});
mockLocalCacheService.saveLibraryCache.mockImplementation(async () => {
callOrder.push('local-cache-save-library');
});
mockLocalCacheService.saveLfsCache.mockImplementation(async () => {
callOrder.push('local-cache-save-lfs');
});
mockChildWorkspaceService.saveWorkspace.mockImplementation(() => {
callOrder.push('child-workspace-save');
});
await runIndex({
childWorkspacesEnabled: true,
childWorkspaceName: 'TurnOfWar',
submoduleProfilePath: '/profile.yml',
lfsTransferAgent: '/tools/agent',
localCacheEnabled: true,
localCacheLfs: true,
localCacheLibrary: true,
gitHooksEnabled: true,
});
// Verify restore operations happen before save operations.
// The expected order from index.ts is:
// 1. Child workspace restore
// 2. Submodule profile init
// 3. LFS agent configure
// 4. Local cache restore (LFS then Library)
// 5. Git hooks install
// 6. [BUILD happens here - Docker.run or MacBuilder.run]
// 7. Local cache save (Library then LFS)
// 8. Child workspace save
const restoreOps = [
'child-workspace-restore',
'submodule-profile-plan',
'submodule-profile-execute',
'lfs-agent-configure',
'local-cache-restore-lfs',
'local-cache-restore-library',
'git-hooks-install',
];
const saveOps = ['local-cache-save-library', 'local-cache-save-lfs', 'child-workspace-save'];
// All restore ops should appear before all save ops
for (const restoreOp of restoreOps) {
if (!callOrder.includes(restoreOp)) continue; // Skip if the operation wasn't called
for (const saveOp of saveOps) {
if (!callOrder.includes(saveOp)) continue;
expect(callOrder.indexOf(restoreOp)).toBeLessThan(callOrder.indexOf(saveOp));
}
}
// Child workspace save should be last
if (callOrder.includes('child-workspace-save') && callOrder.includes('local-cache-save-lfs')) {
expect(callOrder.indexOf('local-cache-save-lfs')).toBeLessThan(callOrder.indexOf('child-workspace-save'));
}
});
});
// -----------------------------------------------------------------------
// Non-local provider strategy
// -----------------------------------------------------------------------
describe('non-local provider strategy', () => {
it('should skip all enterprise features when providerStrategy is not local', async () => {
await runIndex({
providerStrategy: 'aws',
childWorkspacesEnabled: true,
childWorkspaceName: 'Test',
submoduleProfilePath: '/profile.yml',
lfsTransferAgent: '/tools/agent',
localCacheEnabled: true,
gitHooksEnabled: true,
});
// None of the enterprise services should be called because
// they are inside the `if (providerStrategy === 'local')` block
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
expect(mockSubmoduleProfileService.createInitPlan).not.toHaveBeenCalled();
expect(mockLfsAgentService.configure).not.toHaveBeenCalled();
expect(mockLocalCacheService.resolveCacheRoot).not.toHaveBeenCalled();
expect(mockGitHooksService.installHooks).not.toHaveBeenCalled();
});
});
});
+3 -366
View File
@@ -1,18 +1,8 @@
import * as core from '@actions/core'; import * as core from '@actions/core';
import path from 'node:path'; import { Action, BuildParameters, Cache, CloudRunner, Docker, ImageTag, Output } from './model';
import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output } from './model';
import { Cli } from './model/cli/cli'; import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder'; import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup'; import PlatformSetup from './model/platform-setup';
import { BuildReliabilityService } from './model/orchestrator/services/reliability';
import { TestWorkflowService } from './model/orchestrator/services/test-workflow';
import { HotRunnerService } from './model/orchestrator/services/hot-runner';
import { HotRunnerConfig } from './model/orchestrator/services/hot-runner/hot-runner-types';
import { OutputService } from './model/orchestrator/services/output/output-service';
import { OutputTypeRegistry } from './model/orchestrator/services/output/output-type-registry';
import { ArtifactUploadHandler } from './model/orchestrator/services/output/artifact-upload-handler';
import { IncrementalSyncService } from './model/orchestrator/services/sync';
import { SyncStrategy } from './model/orchestrator/services/sync/sync-state';
async function runMain() { async function runMain() {
try { try {
@@ -24,191 +14,15 @@ async function runMain() {
Action.checkCompatibility(); Action.checkCompatibility();
Cache.verify(); Cache.verify();
// Always configure git environment for CI reliability
BuildReliabilityService.configureGitEnvironment();
const { workspace, actionFolder } = Action; const { workspace, actionFolder } = Action;
const buildParameters = await BuildParameters.create(); const buildParameters = await BuildParameters.create();
// If a test suite path is provided, use the test workflow engine
// instead of the standard build execution path
if (buildParameters.testSuitePath) {
core.info('[TestWorkflow] Test suite path detected, using test workflow engine');
const results = await TestWorkflowService.executeTestSuite(buildParameters.testSuitePath, buildParameters);
const totalFailed = results.reduce((sum, r) => sum + r.failed, 0);
if (totalFailed > 0) {
core.setFailed(`Test workflow completed with ${totalFailed} failure(s)`);
} else {
core.info('[TestWorkflow] All test runs passed');
}
return;
}
const baseImage = new ImageTag(buildParameters); const baseImage = new ImageTag(buildParameters);
// Pre-build reliability checks
if (buildParameters.gitIntegrityCheck) {
core.info('Running git integrity checks...');
const isHealthy = BuildReliabilityService.checkGitIntegrity(workspace);
BuildReliabilityService.cleanStaleLockFiles(workspace);
BuildReliabilityService.validateSubmoduleBackingStores(workspace);
if (buildParameters.cleanReservedFilenames) {
BuildReliabilityService.cleanReservedFilenames(buildParameters.projectPath);
}
if (!isHealthy && buildParameters.gitAutoRecover) {
core.info('Git corruption detected, attempting automatic recovery...');
const recovered = BuildReliabilityService.recoverCorruptedRepo(workspace);
if (!recovered) {
core.warning('Automatic recovery failed. Build may encounter issues.');
}
}
} else if (buildParameters.cleanReservedFilenames) {
// cleanReservedFilenames can run independently of gitIntegrityCheck
BuildReliabilityService.cleanReservedFilenames(buildParameters.projectPath);
}
let exitCode = -1; let exitCode = -1;
// Hot runner path: attempt to use a persistent Unity editor instance if (buildParameters.providerStrategy === 'local') {
if (buildParameters.hotRunnerEnabled) {
core.info('[HotRunner] Hot runner mode enabled, attempting hot build...');
const hotRunnerConfig: HotRunnerConfig = {
enabled: true,
transport: buildParameters.hotRunnerTransport,
host: buildParameters.hotRunnerHost,
port: buildParameters.hotRunnerPort,
healthCheckInterval: buildParameters.hotRunnerHealthInterval,
maxIdleTime: buildParameters.hotRunnerMaxIdle,
maxJobsBeforeRecycle: 0, // no automatic recycle by job count
};
const hotRunnerService = new HotRunnerService();
try {
await hotRunnerService.initialize(hotRunnerConfig);
const result = await hotRunnerService.submitBuild(buildParameters, (output) => {
core.info(output);
});
exitCode = result.exitCode;
core.info(`[HotRunner] Build completed with exit code ${exitCode}`);
await hotRunnerService.shutdown();
} catch (hotRunnerError) {
await hotRunnerService.shutdown();
if (buildParameters.hotRunnerFallbackToCold) {
core.warning(
`[HotRunner] Hot runner failed: ${(hotRunnerError as Error).message}. Falling back to cold build.`,
);
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
} else {
throw hotRunnerError;
}
}
} else if (buildParameters.providerStrategy === 'local') {
core.info('Building locally'); core.info('Building locally');
// Child workspace isolation - restore cached workspace before any other setup
let childWorkspaceConfig: any;
if (buildParameters.childWorkspacesEnabled && buildParameters.childWorkspaceName) {
const { ChildWorkspaceService } = await import('./model/orchestrator/services/cache/child-workspace-service');
const cacheRoot =
buildParameters.childWorkspaceCacheRoot ||
path.join(buildParameters.runnerTempPath || process.env.RUNNER_TEMP || '', 'game-ci-workspaces');
childWorkspaceConfig = ChildWorkspaceService.buildConfig({
childWorkspacesEnabled: buildParameters.childWorkspacesEnabled,
childWorkspaceName: buildParameters.childWorkspaceName,
childWorkspaceCacheRoot: cacheRoot,
childWorkspacePreserveGit: buildParameters.childWorkspacePreserveGit,
childWorkspaceSeparateLibrary: buildParameters.childWorkspaceSeparateLibrary,
});
const projectFullPath = path.join(workspace, buildParameters.projectPath);
const restored = ChildWorkspaceService.initializeWorkspace(projectFullPath, childWorkspaceConfig);
core.info(
`Child workspace "${buildParameters.childWorkspaceName}": ${
restored ? 'restored from cache' : 'starting fresh'
}`,
);
// Log workspace size for resource tracking
const size = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
core.info(`Child workspace size after restore: ${size}`);
}
// Submodule profile initialization
if (buildParameters.submoduleProfilePath) {
const { SubmoduleProfileService } = await import(
'./model/orchestrator/services/submodule/submodule-profile-service'
);
core.info('Initializing submodules from profile...');
const plan = await SubmoduleProfileService.createInitPlan(
buildParameters.submoduleProfilePath,
buildParameters.submoduleVariantPath,
workspace,
);
await SubmoduleProfileService.execute(
plan,
workspace,
buildParameters.submoduleToken || buildParameters.gitPrivateToken,
);
}
// Configure custom LFS transfer agent
if (buildParameters.lfsTransferAgent) {
const { LfsAgentService } = await import('./model/orchestrator/services/lfs/lfs-agent-service');
core.info('Configuring custom LFS transfer agent...');
await LfsAgentService.configure(
buildParameters.lfsTransferAgent,
buildParameters.lfsTransferAgentArgs,
buildParameters.lfsStoragePaths ? buildParameters.lfsStoragePaths.split(';') : [],
workspace,
);
}
// Local build caching - restore
let cacheRoot = '';
let cacheKey = '';
if (buildParameters.localCacheEnabled) {
const { LocalCacheService } = await import('./model/orchestrator/services/cache/local-cache-service');
cacheRoot = LocalCacheService.resolveCacheRoot(buildParameters);
cacheKey = LocalCacheService.generateCacheKey(
buildParameters.targetPlatform,
buildParameters.editorVersion,
buildParameters.branch || '',
);
if (buildParameters.localCacheLfs) {
await LocalCacheService.restoreLfsCache(workspace, cacheRoot, cacheKey);
}
if (buildParameters.localCacheLibrary) {
const projectFullPath = path.join(workspace, buildParameters.projectPath);
await LocalCacheService.restoreLibraryCache(projectFullPath, cacheRoot, cacheKey);
}
}
// Git hooks — opt-in only. When disabled (default), do not touch hooks at all.
if (buildParameters.gitHooksEnabled) {
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
await GitHooksService.installHooks(workspace);
if (buildParameters.gitHooksSkipList) {
const environment = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
Object.assign(process.env, environment);
}
}
// Apply incremental sync strategy before build
const syncStrategy = buildParameters.syncStrategy as SyncStrategy;
if (syncStrategy !== 'full') {
core.info(`[Sync] Applying sync strategy: ${syncStrategy}`);
await applySyncStrategy(buildParameters, workspace);
}
await PlatformSetup.setup(buildParameters, actionFolder); await PlatformSetup.setup(buildParameters, actionFolder);
exitCode = exitCode =
process.platform === 'darwin' process.platform === 'darwin'
@@ -218,115 +32,16 @@ async function runMain() {
actionFolder, actionFolder,
...buildParameters, ...buildParameters,
}); });
// Local build caching - save
if (buildParameters.localCacheEnabled) {
const { LocalCacheService } = await import('./model/orchestrator/services/cache/local-cache-service');
if (buildParameters.localCacheLibrary) {
const projectFullPath = path.join(workspace, buildParameters.projectPath);
await LocalCacheService.saveLibraryCache(projectFullPath, cacheRoot, cacheKey);
}
if (buildParameters.localCacheLfs) {
await LocalCacheService.saveLfsCache(workspace, cacheRoot, cacheKey);
}
}
// Child workspace isolation - save workspace for next run
if (childWorkspaceConfig && childWorkspaceConfig.enabled) {
const { ChildWorkspaceService } = await import('./model/orchestrator/services/cache/child-workspace-service');
const projectFullPath = path.join(workspace, buildParameters.projectPath);
const preSaveSize = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
core.info(`Child workspace size before save: ${preSaveSize}`);
ChildWorkspaceService.saveWorkspace(projectFullPath, childWorkspaceConfig);
core.info(`Child workspace "${buildParameters.childWorkspaceName}" saved to cache`);
}
// Revert overlays after job completion if configured
if (buildParameters.syncRevertAfter && syncStrategy !== 'full') {
core.info('[Sync] Reverting overlay changes after job completion');
try {
await IncrementalSyncService.revertOverlays(workspace, buildParameters.syncStatePath);
} catch (revertError) {
core.warning(`[Sync] Overlay revert failed: ${(revertError as Error).message}`);
}
}
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
} else { } else {
await Orchestrator.run(buildParameters, baseImage.toString()); await CloudRunner.run(buildParameters, baseImage.toString());
exitCode = 0; exitCode = 0;
} }
// Post-build: archive and enforce retention
if (buildParameters.buildArchiveEnabled && exitCode === 0) {
core.info('Archiving build output...');
BuildReliabilityService.archiveBuildOutput(buildParameters.buildPath, buildParameters.buildArchivePath);
BuildReliabilityService.enforceRetention(buildParameters.buildArchivePath, buildParameters.buildArchiveRetention);
}
// Set output // Set output
await Output.setBuildVersion(buildParameters.buildVersion); await Output.setBuildVersion(buildParameters.buildVersion);
await Output.setAndroidVersionCode(buildParameters.androidVersionCode); await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
await Output.setEngineExitCode(exitCode); await Output.setEngineExitCode(exitCode);
// Artifact collection and upload (runs on both success and failure)
try {
// Register custom output types if provided
if (buildParameters.artifactCustomTypes) {
try {
const customTypes = JSON.parse(buildParameters.artifactCustomTypes);
if (Array.isArray(customTypes)) {
for (const ct of customTypes) {
OutputTypeRegistry.registerType({
name: ct.name,
defaultPath: ct.defaultPath || ct.pattern || `./${ct.name}/`,
description: ct.description || `Custom output type: ${ct.name}`,
builtIn: false,
});
}
}
} catch (parseError) {
core.warning(`Failed to parse artifactCustomTypes: ${(parseError as Error).message}`);
}
}
// Collect outputs and generate manifest
const manifestPath = path.join(buildParameters.projectPath, 'output-manifest.json');
const manifest = await OutputService.collectOutputs(
buildParameters.projectPath,
buildParameters.buildGuid,
buildParameters.artifactOutputTypes,
manifestPath,
);
core.setOutput('artifactManifestPath', manifestPath);
// Upload artifacts
const uploadConfig = ArtifactUploadHandler.parseConfig(
buildParameters.artifactUploadTarget,
buildParameters.artifactUploadPath || undefined,
buildParameters.artifactCompression,
buildParameters.artifactRetentionDays,
);
const uploadResult = await ArtifactUploadHandler.uploadArtifacts(
manifest,
uploadConfig,
buildParameters.projectPath,
);
if (!uploadResult.success) {
core.warning(
`Artifact upload completed with errors: ${uploadResult.entries
.filter((e) => !e.success)
.map((e) => `${e.type}: ${e.error}`)
.join('; ')}`,
);
}
} catch (artifactError) {
core.warning(`Artifact collection/upload failed: ${(artifactError as Error).message}`);
}
if (exitCode !== 0) { if (exitCode !== 0) {
core.setFailed(`Build failed with exit code ${exitCode}`); core.setFailed(`Build failed with exit code ${exitCode}`);
} }
@@ -335,82 +50,4 @@ async function runMain() {
} }
} }
async function runColdBuild(
buildParameters: BuildParameters,
baseImage: ImageTag,
workspace: string,
actionFolder: string,
): Promise<number> {
if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
await PlatformSetup.setup(buildParameters, actionFolder);
return process.platform === 'darwin'
? await MacBuilder.run(actionFolder)
: await Docker.run(baseImage.toString(), {
workspace,
actionFolder,
...buildParameters,
});
} else {
await Orchestrator.run(buildParameters, baseImage.toString());
return 0;
}
}
/**
* Apply the configured sync strategy to the workspace before build.
*/
async function applySyncStrategy(buildParameters: BuildParameters, workspace: string): Promise<void> {
const strategy = buildParameters.syncStrategy as SyncStrategy;
const resolvedStrategy = IncrementalSyncService.resolveStrategy(strategy, workspace, buildParameters.syncStatePath);
if (resolvedStrategy === 'full') {
core.info('[Sync] Resolved to full sync (no incremental state available)');
return;
}
switch (resolvedStrategy) {
case 'git-delta': {
const targetReference = buildParameters.gitSha || buildParameters.branch;
const changedFiles = await IncrementalSyncService.syncGitDelta(
workspace,
targetReference,
buildParameters.syncStatePath,
);
core.info(`[Sync] Git delta sync applied: ${changedFiles} file(s) changed`);
break;
}
case 'direct-input': {
if (!buildParameters.syncInputRef) {
throw new Error('[Sync] direct-input strategy requires syncInputRef to be set');
}
const overlays = await IncrementalSyncService.applyDirectInput(
workspace,
buildParameters.syncInputRef,
buildParameters.syncStorageRemote || undefined,
buildParameters.syncStatePath,
);
core.info(`[Sync] Direct input applied: ${overlays.length} overlay(s)`);
break;
}
case 'storage-pull': {
if (!buildParameters.syncInputRef) {
throw new Error('[Sync] storage-pull strategy requires syncInputRef to be set');
}
const pulledFiles = await IncrementalSyncService.syncStoragePull(workspace, buildParameters.syncInputRef, {
rcloneRemote: buildParameters.syncStorageRemote || undefined,
syncRevertAfter: buildParameters.syncRevertAfter,
statePath: buildParameters.syncStatePath,
});
core.info(`[Sync] Storage pull complete: ${pulledFiles.length} file(s)`);
break;
}
default:
core.warning(`[Sync] Unknown sync strategy: ${resolvedStrategy}`);
}
}
runMain(); runMain();
@@ -1,13 +1,13 @@
// Integration test for exercising real GitHub check creation and updates. // Integration test for exercising real GitHub check creation and updates.
import Orchestrator from '../model/orchestrator/orchestrator'; import CloudRunner from '../model/cloud-runner/cloud-runner';
import UnityVersioning from '../model/unity-versioning'; import UnityVersioning from '../model/unity-versioning';
import GitHub from '../model/github'; import GitHub from '../model/github';
import { TIMEOUT_INFINITE, createParameters } from '../test-utils/orchestrator-test-helpers'; import { TIMEOUT_INFINITE, createParameters } from '../test-utils/cloud-runner-test-helpers';
const runIntegration = process.env.RUN_GITHUB_INTEGRATION_TESTS === 'true'; const runIntegration = process.env.RUN_GITHUB_INTEGRATION_TESTS === 'true';
const describeOrSkip = runIntegration ? describe : describe.skip; const describeOrSkip = runIntegration ? describe : describe.skip;
describeOrSkip('Orchestrator Github Checks Integration', () => { describeOrSkip('Cloud Runner Github Checks Integration', () => {
it( it(
'creates and updates a real GitHub check', 'creates and updates a real GitHub check',
async () => { async () => {
@@ -15,10 +15,10 @@ describeOrSkip('Orchestrator Github Checks Integration', () => {
versioning: 'None', versioning: 'None',
projectPath: 'test-project', projectPath: 'test-project',
unityVersion: UnityVersioning.read('test-project'), unityVersion: UnityVersioning.read('test-project'),
asyncOrchestrator: `true`, asyncCloudRunner: `true`,
githubChecks: `true`, githubChecks: `true`,
}); });
await Orchestrator.setup(buildParameter); await CloudRunner.setup(buildParameter);
const checkId = await GitHub.createGitHubCheck(`integration create`); const checkId = await GitHub.createGitHubCheck(`integration create`);
expect(checkId).not.toEqual(''); expect(checkId).not.toEqual('');
await GitHub.updateGitHubCheck(`1 ${new Date().toISOString()}`, `integration`); await GitHub.updateGitHubCheck(`1 ${new Date().toISOString()}`, `integration`);
+46 -255
View File
@@ -1,7 +1,7 @@
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import AndroidVersioning from './android-versioning'; import AndroidVersioning from './android-versioning';
import OrchestratorConstants from './orchestrator/options/orchestrator-constants'; import CloudRunnerConstants from './cloud-runner/options/cloud-runner-constants';
import OrchestratorBuildGuid from './orchestrator/options/orchestrator-guid'; import CloudRunnerBuildGuid from './cloud-runner/options/cloud-runner-guid';
import Input from './input'; import Input from './input';
import Platform from './platform'; import Platform from './platform';
import UnityVersioning from './unity-versioning'; import UnityVersioning from './unity-versioning';
@@ -10,8 +10,8 @@ import { GitRepoReader } from './input-readers/git-repo';
import { GithubCliReader } from './input-readers/github-cli'; import { GithubCliReader } from './input-readers/github-cli';
import { Cli } from './cli/cli'; import { Cli } from './cli/cli';
import GitHub from './github'; import GitHub from './github';
import OrchestratorOptions from './orchestrator/options/orchestrator-options'; import CloudRunnerOptions from './cloud-runner/options/cloud-runner-options';
import Orchestrator from './orchestrator/orchestrator'; import CloudRunner from './cloud-runner/cloud-runner';
import * as core from '@actions/core'; import * as core from '@actions/core';
class BuildParameters { class BuildParameters {
@@ -54,13 +54,6 @@ class BuildParameters {
public sshAgent!: string; public sshAgent!: string;
public sshPublicKeysDirectoryPath!: string; public sshPublicKeysDirectoryPath!: string;
public providerStrategy!: string; public providerStrategy!: string;
public fallbackProviderStrategy!: string;
public runnerCheckEnabled!: boolean;
public runnerCheckLabels!: string[];
public runnerCheckMinAvailable!: number;
public retryOnFallback!: boolean;
public providerInitTimeout!: number;
public gitAuthMode!: string;
public gitPrivateToken!: string; public gitPrivateToken!: string;
public awsStackName!: string; public awsStackName!: string;
public awsEndpoint?: string; public awsEndpoint?: string;
@@ -74,7 +67,6 @@ class BuildParameters {
public kubeConfig!: string; public kubeConfig!: string;
public containerMemory!: string; public containerMemory!: string;
public containerCpu!: string; public containerCpu!: string;
public containerNamespace!: string;
public kubeVolumeSize!: string; public kubeVolumeSize!: string;
public kubeVolume!: string; public kubeVolume!: string;
public kubeStorageClass!: string; public kubeStorageClass!: string;
@@ -91,13 +83,11 @@ class BuildParameters {
public runNumber!: string; public runNumber!: string;
public branch!: string; public branch!: string;
public githubRepo!: string; public githubRepo!: string;
public orchestratorRepoName!: string;
public cloneDepth!: number;
public gitSha!: string; public gitSha!: string;
public logId!: string; public logId!: string;
public buildGuid!: string; public buildGuid!: string;
public orchestratorBranch!: string; public cloudRunnerBranch!: string;
public orchestratorDebug!: boolean | undefined; public cloudRunnerDebug!: boolean | undefined;
public buildPlatform!: string | undefined; public buildPlatform!: string | undefined;
public isCliMode!: boolean; public isCliMode!: boolean;
public maxRetainedWorkspaces!: number; public maxRetainedWorkspaces!: number;
@@ -113,106 +103,9 @@ class BuildParameters {
public cacheUnityInstallationOnMac!: boolean; public cacheUnityInstallationOnMac!: boolean;
public unityHubVersionOnMac!: string; public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: string; public dockerWorkspacePath!: string;
public submoduleProfilePath!: string;
public submoduleVariantPath!: string;
public submoduleToken!: string;
public localCacheEnabled!: boolean;
public localCacheRoot!: string;
public localCacheLibrary!: boolean;
public localCacheLfs!: boolean;
public childWorkspacesEnabled!: boolean;
public childWorkspaceName!: string;
public childWorkspaceCacheRoot!: string;
public childWorkspacePreserveGit!: boolean;
public childWorkspaceSeparateLibrary!: boolean;
public lfsTransferAgent!: string;
public lfsTransferAgentArgs!: string;
public lfsStoragePaths!: string;
public gitHooksEnabled!: boolean;
public gitHooksSkipList!: string;
public gitHooksRunBeforeBuild!: string;
public providerExecutable!: string;
// GCP Cloud Run (Experimental)
public gcpProject!: string;
public gcpRegion!: string;
public gcpStorageType!: string;
public gcpBucket!: string;
public gcpFilestoreIp!: string;
public gcpFilestoreShare!: string;
public gcpMachineType!: string;
public gcpDiskSizeGb!: string;
public gcpServiceAccount!: string;
public gcpVpcConnector!: string;
// Azure Container Instances (Experimental)
public azureResourceGroup!: string;
public azureLocation!: string;
public azureStorageType!: string;
public azureStorageAccount!: string;
public azureBlobContainer!: string;
public azureFileShareName!: string;
public azureSubscriptionId!: string;
public azureCpu!: string;
public azureMemoryGb!: string;
public azureDiskSizeGb!: string;
public azureSubnetId!: string;
// Remote PowerShell provider
public remotePowershellHost!: string;
public remotePowershellCredential!: string;
public remotePowershellTransport!: string;
// GitHub Actions provider
public githubActionsRepo!: string;
public githubActionsWorkflow!: string;
public githubActionsToken!: string;
public githubActionsRef!: string;
// GitLab CI provider
public gitlabProjectId!: string;
public gitlabTriggerToken!: string;
public gitlabApiUrl!: string;
public gitlabRef!: string;
// Ansible provider
public ansibleInventory!: string;
public ansiblePlaybook!: string;
public ansibleExtraVars!: string;
public ansibleVaultPassword!: string;
public gitIntegrityCheck!: boolean;
public gitAutoRecover!: boolean;
public cleanReservedFilenames!: boolean;
public buildArchiveEnabled!: boolean;
public buildArchivePath!: string;
public buildArchiveRetention!: number;
public testSuitePath!: string;
public testSuiteEvent!: string;
public testTaxonomyPath!: string;
public testResultFormat!: string;
public testResultPath!: string;
public hotRunnerEnabled!: boolean;
public hotRunnerTransport!: 'websocket' | 'grpc' | 'named-pipe';
public hotRunnerHost!: string;
public hotRunnerPort!: number;
public hotRunnerHealthInterval!: number;
public hotRunnerMaxIdle!: number;
public hotRunnerFallbackToCold!: boolean;
public artifactOutputTypes!: string;
public artifactUploadTarget!: string;
public artifactUploadPath!: string;
public artifactCompression!: string;
public artifactRetentionDays!: string;
public artifactCustomTypes!: string;
public syncStrategy!: string;
public syncInputRef!: string;
public syncStorageRemote!: string;
public syncRevertAfter!: boolean;
public syncStatePath!: string;
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) { public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``; return buildParameters.maxRetainedWorkspaces > 0 && CloudRunner.lockedWorkspace !== ``;
} }
static async create(): Promise<BuildParameters> { static async create(): Promise<BuildParameters> {
@@ -297,154 +190,52 @@ class BuildParameters {
dockerIsolationMode: Input.dockerIsolationMode, dockerIsolationMode: Input.dockerIsolationMode,
containerRegistryRepository: Input.containerRegistryRepository, containerRegistryRepository: Input.containerRegistryRepository,
containerRegistryImageVersion: Input.containerRegistryImageVersion, containerRegistryImageVersion: Input.containerRegistryImageVersion,
providerStrategy: OrchestratorOptions.providerStrategy, providerStrategy: CloudRunnerOptions.providerStrategy,
fallbackProviderStrategy: OrchestratorOptions.fallbackProviderStrategy, buildPlatform: CloudRunnerOptions.buildPlatform,
runnerCheckEnabled: OrchestratorOptions.runnerCheckEnabled, kubeConfig: CloudRunnerOptions.kubeConfig,
runnerCheckLabels: OrchestratorOptions.runnerCheckLabels, containerMemory: CloudRunnerOptions.containerMemory,
runnerCheckMinAvailable: OrchestratorOptions.runnerCheckMinAvailable, containerCpu: CloudRunnerOptions.containerCpu,
retryOnFallback: OrchestratorOptions.retryOnFallback, kubeVolumeSize: CloudRunnerOptions.kubeVolumeSize,
providerInitTimeout: OrchestratorOptions.providerInitTimeout, kubeVolume: CloudRunnerOptions.kubeVolume,
gitAuthMode: OrchestratorOptions.gitAuthMode, postBuildContainerHooks: CloudRunnerOptions.postBuildContainerHooks,
buildPlatform: OrchestratorOptions.buildPlatform, preBuildContainerHooks: CloudRunnerOptions.preBuildContainerHooks,
kubeConfig: OrchestratorOptions.kubeConfig, customJob: CloudRunnerOptions.customJob,
containerMemory: OrchestratorOptions.containerMemory,
containerCpu: OrchestratorOptions.containerCpu,
containerNamespace: OrchestratorOptions.containerNamespace,
kubeVolumeSize: OrchestratorOptions.kubeVolumeSize,
kubeVolume: OrchestratorOptions.kubeVolume,
postBuildContainerHooks: OrchestratorOptions.postBuildContainerHooks,
preBuildContainerHooks: OrchestratorOptions.preBuildContainerHooks,
customJob: OrchestratorOptions.customJob,
runNumber: Input.runNumber, runNumber: Input.runNumber,
branch: Input.branch.replace('/head', '') || (await GitRepoReader.GetBranch()), branch: Input.branch.replace('/head', '') || (await GitRepoReader.GetBranch()),
orchestratorBranch: OrchestratorOptions.orchestratorBranch.split('/').reverse()[0], cloudRunnerBranch: CloudRunnerOptions.cloudRunnerBranch.split('/').reverse()[0],
orchestratorDebug: OrchestratorOptions.orchestratorDebug, cloudRunnerDebug: CloudRunnerOptions.cloudRunnerDebug,
githubRepo: (Input.githubRepo ?? (await GitRepoReader.GetRemote())) || OrchestratorOptions.orchestratorRepoName, githubRepo: (Input.githubRepo ?? (await GitRepoReader.GetRemote())) || 'game-ci/unity-builder',
orchestratorRepoName: OrchestratorOptions.orchestratorRepoName,
cloneDepth: Number.parseInt(OrchestratorOptions.cloneDepth),
isCliMode: Cli.isCliMode, isCliMode: Cli.isCliMode,
awsStackName: OrchestratorOptions.awsStackName, awsStackName: CloudRunnerOptions.awsStackName,
awsEndpoint: OrchestratorOptions.awsEndpoint, awsEndpoint: CloudRunnerOptions.awsEndpoint,
awsCloudFormationEndpoint: OrchestratorOptions.awsCloudFormationEndpoint, awsCloudFormationEndpoint: CloudRunnerOptions.awsCloudFormationEndpoint,
awsEcsEndpoint: OrchestratorOptions.awsEcsEndpoint, awsEcsEndpoint: CloudRunnerOptions.awsEcsEndpoint,
awsKinesisEndpoint: OrchestratorOptions.awsKinesisEndpoint, awsKinesisEndpoint: CloudRunnerOptions.awsKinesisEndpoint,
awsCloudWatchLogsEndpoint: OrchestratorOptions.awsCloudWatchLogsEndpoint, awsCloudWatchLogsEndpoint: CloudRunnerOptions.awsCloudWatchLogsEndpoint,
awsS3Endpoint: OrchestratorOptions.awsS3Endpoint, awsS3Endpoint: CloudRunnerOptions.awsS3Endpoint,
storageProvider: OrchestratorOptions.storageProvider, storageProvider: CloudRunnerOptions.storageProvider,
rcloneRemote: OrchestratorOptions.rcloneRemote, rcloneRemote: CloudRunnerOptions.rcloneRemote,
gitSha: Input.gitSha, gitSha: Input.gitSha,
logId: customAlphabet(OrchestratorConstants.alphabet, 9)(), logId: customAlphabet(CloudRunnerConstants.alphabet, 9)(),
buildGuid: OrchestratorBuildGuid.generateGuid(Input.runNumber, Input.targetPlatform), buildGuid: CloudRunnerBuildGuid.generateGuid(Input.runNumber, Input.targetPlatform),
commandHooks: OrchestratorOptions.commandHooks, commandHooks: CloudRunnerOptions.commandHooks,
inputPullCommand: OrchestratorOptions.inputPullCommand, inputPullCommand: CloudRunnerOptions.inputPullCommand,
pullInputList: OrchestratorOptions.pullInputList, pullInputList: CloudRunnerOptions.pullInputList,
kubeStorageClass: OrchestratorOptions.kubeStorageClass, kubeStorageClass: CloudRunnerOptions.kubeStorageClass,
gcpProject: Input.gcpProject, cacheKey: CloudRunnerOptions.cacheKey,
gcpRegion: Input.gcpRegion, maxRetainedWorkspaces: Number.parseInt(CloudRunnerOptions.maxRetainedWorkspaces),
gcpStorageType: Input.gcpStorageType, useLargePackages: CloudRunnerOptions.useLargePackages,
gcpBucket: Input.gcpBucket, useCompressionStrategy: CloudRunnerOptions.useCompressionStrategy,
gcpFilestoreIp: Input.gcpFilestoreIp, garbageMaxAge: CloudRunnerOptions.garbageMaxAge,
gcpFilestoreShare: Input.gcpFilestoreShare, githubChecks: CloudRunnerOptions.githubChecks,
gcpMachineType: Input.gcpMachineType, asyncWorkflow: CloudRunnerOptions.asyncCloudRunner,
gcpDiskSizeGb: Input.gcpDiskSizeGb, githubCheckId: CloudRunnerOptions.githubCheckId,
gcpServiceAccount: Input.gcpServiceAccount, finalHooks: CloudRunnerOptions.finalHooks,
gcpVpcConnector: Input.gcpVpcConnector, skipLfs: CloudRunnerOptions.skipLfs,
azureResourceGroup: Input.azureResourceGroup, skipCache: CloudRunnerOptions.skipCache,
azureLocation: Input.azureLocation,
azureStorageType: Input.azureStorageType,
azureStorageAccount: Input.azureStorageAccount,
azureBlobContainer: Input.azureBlobContainer,
azureFileShareName: Input.azureFileShareName,
azureSubscriptionId: Input.azureSubscriptionId,
azureCpu: Input.azureCpu,
azureMemoryGb: Input.azureMemoryGb,
azureDiskSizeGb: Input.azureDiskSizeGb,
azureSubnetId: Input.azureSubnetId,
cacheKey: OrchestratorOptions.cacheKey,
maxRetainedWorkspaces: Number.parseInt(OrchestratorOptions.maxRetainedWorkspaces),
useLargePackages: OrchestratorOptions.useLargePackages,
useCompressionStrategy: OrchestratorOptions.useCompressionStrategy,
garbageMaxAge: OrchestratorOptions.garbageMaxAge,
githubChecks: OrchestratorOptions.githubChecks,
asyncWorkflow: OrchestratorOptions.asyncOrchestrator,
githubCheckId: OrchestratorOptions.githubCheckId,
finalHooks: OrchestratorOptions.finalHooks,
skipLfs: OrchestratorOptions.skipLfs,
skipCache: OrchestratorOptions.skipCache,
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac, cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac, unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath, dockerWorkspacePath: Input.dockerWorkspacePath,
submoduleProfilePath: Input.submoduleProfilePath,
submoduleVariantPath: Input.submoduleVariantPath,
submoduleToken: Input.submoduleToken,
localCacheEnabled: Input.localCacheEnabled,
localCacheRoot: Input.localCacheRoot,
localCacheLibrary: Input.localCacheLibrary,
localCacheLfs: Input.localCacheLfs,
childWorkspacesEnabled: Input.childWorkspacesEnabled,
childWorkspaceName: Input.childWorkspaceName,
childWorkspaceCacheRoot: Input.childWorkspaceCacheRoot,
childWorkspacePreserveGit: Input.childWorkspacePreserveGit,
childWorkspaceSeparateLibrary: Input.childWorkspaceSeparateLibrary,
lfsTransferAgent: Input.lfsTransferAgent,
lfsTransferAgentArgs: Input.lfsTransferAgentArgs,
lfsStoragePaths: Input.lfsStoragePaths,
gitHooksEnabled: Input.gitHooksEnabled,
gitHooksSkipList: Input.gitHooksSkipList,
gitHooksRunBeforeBuild: Input.gitHooksRunBeforeBuild,
providerExecutable: Input.providerExecutable,
// Remote PowerShell provider
remotePowershellHost: Input.remotePowershellHost,
remotePowershellCredential: Input.remotePowershellCredential,
remotePowershellTransport: Input.remotePowershellTransport,
// GitHub Actions provider
githubActionsRepo: Input.githubActionsRepo,
githubActionsWorkflow: Input.githubActionsWorkflow,
githubActionsToken: Input.githubActionsToken,
githubActionsRef: Input.githubActionsRef,
// GitLab CI provider
gitlabProjectId: Input.gitlabProjectId,
gitlabTriggerToken: Input.gitlabTriggerToken,
gitlabApiUrl: Input.gitlabApiUrl,
gitlabRef: Input.gitlabRef,
// Ansible provider
ansibleInventory: Input.ansibleInventory,
ansiblePlaybook: Input.ansiblePlaybook,
ansibleExtraVars: Input.ansibleExtraVars,
ansibleVaultPassword: Input.ansibleVaultPassword,
gitIntegrityCheck: Input.gitIntegrityCheck,
gitAutoRecover: Input.gitAutoRecover,
cleanReservedFilenames: Input.cleanReservedFilenames,
buildArchiveEnabled: Input.buildArchiveEnabled,
buildArchivePath: Input.buildArchivePath,
buildArchiveRetention: Input.buildArchiveRetention,
testSuitePath: Input.testSuitePath,
testSuiteEvent: Input.testSuiteEvent,
testTaxonomyPath: Input.testTaxonomyPath,
testResultFormat: Input.testResultFormat,
testResultPath: Input.testResultPath,
hotRunnerEnabled: Input.hotRunnerEnabled,
hotRunnerTransport: Input.hotRunnerTransport,
hotRunnerHost: Input.hotRunnerHost,
hotRunnerPort: Input.hotRunnerPort,
hotRunnerHealthInterval: Input.hotRunnerHealthInterval,
hotRunnerMaxIdle: Input.hotRunnerMaxIdle,
hotRunnerFallbackToCold: Input.hotRunnerFallbackToCold,
artifactOutputTypes: Input.artifactOutputTypes,
artifactUploadTarget: Input.artifactUploadTarget,
artifactUploadPath: Input.artifactUploadPath,
artifactCompression: Input.artifactCompression,
artifactRetentionDays: Input.artifactRetentionDays,
artifactCustomTypes: Input.artifactCustomTypes,
syncStrategy: Input.syncStrategy,
syncInputRef: Input.syncInputRef,
syncStorageRemote: Input.syncStorageRemote,
syncRevertAfter: Input.syncRevertAfter,
syncStatePath: Input.syncStatePath,
}; };
} }
+34 -63
View File
@@ -1,19 +1,17 @@
import { Command } from 'commander-ts'; import { Command } from 'commander-ts';
import { BuildParameters, Orchestrator, ImageTag, Input } from '..'; import { BuildParameters, CloudRunner, ImageTag, Input } from '..';
import * as core from '@actions/core'; import * as core from '@actions/core';
import { ActionYamlReader } from '../input-readers/action-yaml'; import { ActionYamlReader } from '../input-readers/action-yaml';
import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger'; import CloudRunnerLogger from '../cloud-runner/services/core/cloud-runner-logger';
import OrchestratorQueryOverride from '../orchestrator/options/orchestrator-query-override'; import CloudRunnerQueryOverride from '../cloud-runner/options/cloud-runner-query-override';
import { CliFunction, CliFunctionsRepository } from './cli-functions-repository'; import { CliFunction, CliFunctionsRepository } from './cli-functions-repository';
import { Caching } from '../orchestrator/remote-client/caching'; import { Caching } from '../cloud-runner/remote-client/caching';
import { LfsHashing } from '../orchestrator/services/utility/lfs-hashing'; import { LfsHashing } from '../cloud-runner/services/utility/lfs-hashing';
import { RemoteClient } from '../orchestrator/remote-client'; import { RemoteClient } from '../cloud-runner/remote-client';
import OrchestratorOptionsReader from '../orchestrator/options/orchestrator-options-reader'; import CloudRunnerOptionsReader from '../cloud-runner/options/cloud-runner-options-reader';
import GitHub from '../github'; import GitHub from '../github';
import { OptionValues } from 'commander'; import { OptionValues } from 'commander';
import { InputKey } from '../input'; import { InputKey } from '../input';
import { SubmoduleProfileService } from '../orchestrator/services/submodule/submodule-profile-service';
import { LfsAgentService } from '../orchestrator/services/lfs/lfs-agent-service';
export class Cli { export class Cli {
public static options: OptionValues | undefined; public static options: OptionValues | undefined;
@@ -38,7 +36,7 @@ export class Cli {
const program = new Command(); const program = new Command();
program.version('0.0.1'); program.version('0.0.1');
const properties = OrchestratorOptionsReader.GetProperties(); const properties = CloudRunnerOptionsReader.GetProperties();
const actionYamlReader: ActionYamlReader = new ActionYamlReader(); const actionYamlReader: ActionYamlReader = new ActionYamlReader();
for (const element of properties) { for (const element of properties) {
program.option(`--${element} <${element}>`, actionYamlReader.GetActionYamlValue(element)); program.option(`--${element} <${element}>`, actionYamlReader.GetActionYamlValue(element));
@@ -55,11 +53,6 @@ export class Cli {
program.option('--artifactName <artifactName>', 'caching artifact name'); program.option('--artifactName <artifactName>', 'caching artifact name');
program.option('--select <select>', 'select a particular resource'); program.option('--select <select>', 'select a particular resource');
program.option('--logFile <logFile>', 'output to log file (log stream only)'); program.option('--logFile <logFile>', 'output to log file (log stream only)');
program.option('--profilePath <profilePath>', 'path to submodule profile YAML');
program.option('--variantPath <variantPath>', 'path to submodule variant YAML');
program.option('--agentPath <agentPath>', 'path to custom LFS transfer agent');
program.option('--agentArgs <agentArgs>', 'arguments for custom LFS transfer agent');
program.option('--storagePaths <storagePaths>', 'semicolon-separated storage paths for LFS agent');
program.parse(process.argv); program.parse(process.argv);
Cli.options = program.opts(); Cli.options = program.opts();
@@ -69,23 +62,23 @@ export class Cli {
static async RunCli(): Promise<void> { static async RunCli(): Promise<void> {
GitHub.githubInputEnabled = false; GitHub.githubInputEnabled = false;
if (Cli.options!['populateOverride'] === `true`) { if (Cli.options!['populateOverride'] === `true`) {
await OrchestratorQueryOverride.PopulateQueryOverrideInput(); await CloudRunnerQueryOverride.PopulateQueryOverrideInput();
} }
if (Cli.options!['logInput']) { if (Cli.options!['logInput']) {
Cli.logInput(); Cli.logInput();
} }
const results = CliFunctionsRepository.GetCliFunctions(Cli.options?.mode); const results = CliFunctionsRepository.GetCliFunctions(Cli.options?.mode);
OrchestratorLogger.log(`Entrypoint: ${results.key}`); CloudRunnerLogger.log(`Entrypoint: ${results.key}`);
Cli.options!.versioning = 'None'; Cli.options!.versioning = 'None';
Orchestrator.buildParameters = await BuildParameters.create(); CloudRunner.buildParameters = await BuildParameters.create();
Orchestrator.buildParameters.buildGuid = process.env.BUILD_GUID || ``; CloudRunner.buildParameters.buildGuid = process.env.BUILD_GUID || ``;
OrchestratorLogger.log(`Build Params: CloudRunnerLogger.log(`Build Params:
${JSON.stringify(Orchestrator.buildParameters, undefined, 4)} ${JSON.stringify(CloudRunner.buildParameters, undefined, 4)}
`); `);
Orchestrator.lockedWorkspace = process.env.LOCKED_WORKSPACE || ``; CloudRunner.lockedWorkspace = process.env.LOCKED_WORKSPACE || ``;
OrchestratorLogger.log(`Locked Workspace: ${Orchestrator.lockedWorkspace}`); CloudRunnerLogger.log(`Locked Workspace: ${CloudRunner.lockedWorkspace}`);
await Orchestrator.setup(Orchestrator.buildParameters); await CloudRunner.setup(CloudRunner.buildParameters);
return await results.target[results.propertyKey](Cli.options); return await results.target[results.propertyKey](Cli.options);
} }
@@ -94,7 +87,7 @@ export class Cli {
private static logInput() { private static logInput() {
core.info(`\n`); core.info(`\n`);
core.info(`INPUT:`); core.info(`INPUT:`);
const properties = OrchestratorOptionsReader.GetProperties(); const properties = CloudRunnerOptionsReader.GetProperties();
for (const element of properties) { for (const element of properties) {
if ( if (
element in Input && element in Input &&
@@ -111,28 +104,28 @@ export class Cli {
core.info(`\n`); core.info(`\n`);
} }
@CliFunction(`cli-build`, `runs a orchestrator build`) @CliFunction(`cli-build`, `runs a cloud runner build`)
public static async CLIBuild(): Promise<string> { public static async CLIBuild(): Promise<string> {
const buildParameter = await BuildParameters.create(); const buildParameter = await BuildParameters.create();
const baseImage = new ImageTag(buildParameter); const baseImage = new ImageTag(buildParameter);
return (await Orchestrator.run(buildParameter, baseImage.toString())).BuildResults; return (await CloudRunner.run(buildParameter, baseImage.toString())).BuildResults;
} }
@CliFunction(`async-workflow`, `runs a orchestrator build`) @CliFunction(`async-workflow`, `runs a cloud runner build`)
public static async asyncronousWorkflow(): Promise<string> { public static async asyncronousWorkflow(): Promise<string> {
const buildParameter = await BuildParameters.create(); const buildParameter = await BuildParameters.create();
const baseImage = new ImageTag(buildParameter); const baseImage = new ImageTag(buildParameter);
await Orchestrator.setup(buildParameter); await CloudRunner.setup(buildParameter);
return (await Orchestrator.run(buildParameter, baseImage.toString())).BuildResults; return (await CloudRunner.run(buildParameter, baseImage.toString())).BuildResults;
} }
@CliFunction(`checks-update`, `runs a orchestrator build`) @CliFunction(`checks-update`, `runs a cloud runner build`)
public static async checksUpdate() { public static async checksUpdate() {
const buildParameter = await BuildParameters.create(); const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter); await CloudRunner.setup(buildParameter);
const input = JSON.parse(process.env.CHECKS_UPDATE || ``); const input = JSON.parse(process.env.CHECKS_UPDATE || ``);
core.info(`Checks Update ${process.env.CHECKS_UPDATE}`); core.info(`Checks Update ${process.env.CHECKS_UPDATE}`);
if (input.mode === `create`) { if (input.mode === `create`) {
@@ -146,18 +139,18 @@ export class Cli {
public static async GarbageCollect(): Promise<string> { public static async GarbageCollect(): Promise<string> {
const buildParameter = await BuildParameters.create(); const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter); await CloudRunner.setup(buildParameter);
return await Orchestrator.Provider.garbageCollect(``, false, 0, false, false); return await CloudRunner.Provider.garbageCollect(``, false, 0, false, false);
} }
@CliFunction(`list-resources`, `lists active resources`) @CliFunction(`list-resources`, `lists active resources`)
public static async ListResources(): Promise<string[]> { public static async ListResources(): Promise<string[]> {
const buildParameter = await BuildParameters.create(); const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter); await CloudRunner.setup(buildParameter);
const result = await Orchestrator.Provider.listResources(); const result = await CloudRunner.Provider.listResources();
OrchestratorLogger.log(JSON.stringify(result, undefined, 4)); CloudRunnerLogger.log(JSON.stringify(result, undefined, 4));
return result.map((x) => x.Name); return result.map((x) => x.Name);
} }
@@ -166,39 +159,17 @@ export class Cli {
public static async ListWorfklow(): Promise<string[]> { public static async ListWorfklow(): Promise<string[]> {
const buildParameter = await BuildParameters.create(); const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter); await CloudRunner.setup(buildParameter);
return (await Orchestrator.Provider.listWorkflow()).map((x) => x.Name); return (await CloudRunner.Provider.listWorkflow()).map((x) => x.Name);
} }
@CliFunction(`watch`, `follows logs of a running workflow`) @CliFunction(`watch`, `follows logs of a running workflow`)
public static async Watch(): Promise<string> { public static async Watch(): Promise<string> {
const buildParameter = await BuildParameters.create(); const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter); await CloudRunner.setup(buildParameter);
return await Orchestrator.Provider.watchWorkflow(); return await CloudRunner.Provider.watchWorkflow();
}
@CliFunction(`submodule-init`, `initializes submodules from a YAML profile`)
public static async SubmoduleInit(): Promise<void> {
const profilePath = Cli.options!['profilePath'];
const variantPath = Cli.options!['variantPath'] || '';
if (!profilePath) {
throw new Error('--profilePath is required for submodule-init');
}
const plan = await SubmoduleProfileService.createInitPlan(profilePath, variantPath, process.cwd());
await SubmoduleProfileService.execute(plan, process.cwd());
}
@CliFunction(`lfs-agent-configure`, `configures a custom LFS transfer agent`)
public static async LfsAgentConfigure(): Promise<void> {
const agentPath = Cli.options!['agentPath'];
if (!agentPath) {
throw new Error('--agentPath is required for lfs-agent-configure');
}
const agentArgs = Cli.options!['agentArgs'] || '';
const storagePaths = (Cli.options!['storagePaths'] || '').split(';').filter(Boolean);
await LfsAgentService.configure(agentPath, agentArgs, storagePaths, process.cwd());
} }
} }
+227
View File
@@ -0,0 +1,227 @@
import AwsBuildPlatform from './providers/aws';
import { BuildParameters, Input } from '..';
import Kubernetes from './providers/k8s';
import CloudRunnerLogger from './services/core/cloud-runner-logger';
import { CloudRunnerStepParameters } from './options/cloud-runner-step-parameters';
import { WorkflowCompositionRoot } from './workflows/workflow-composition-root';
import { CloudRunnerError } from './error/cloud-runner-error';
import { TaskParameterSerializer } from './services/core/task-parameter-serializer';
import * as core from '@actions/core';
import CloudRunnerSecret from './options/cloud-runner-secret';
import { ProviderInterface } from './providers/provider-interface';
import CloudRunnerEnvironmentVariable from './options/cloud-runner-environment-variable';
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';
class CloudRunner {
public static Provider: ProviderInterface;
public static buildParameters: BuildParameters;
private static defaultSecrets: CloudRunnerSecret[];
private static cloudRunnerEnvironmentVariables: CloudRunnerEnvironmentVariable[];
static lockedWorkspace: string = ``;
public static readonly retainedWorkspacePrefix: string = `retained-workspace`;
public static get isCloudRunnerEnvironment() {
return process.env[`GITHUB_ACTIONS`] !== `true`;
}
public static get isCloudRunnerAsyncEnvironment() {
return process.env[`ASYNC_WORKFLOW`] === `true`;
}
public static async setup(buildParameters: BuildParameters) {
CloudRunnerLogger.setup();
CloudRunnerLogger.log(`Setting up cloud runner`);
CloudRunner.buildParameters = buildParameters;
if (CloudRunner.buildParameters.githubCheckId === ``) {
CloudRunner.buildParameters.githubCheckId = await GitHub.createGitHubCheck(CloudRunner.buildParameters.buildGuid);
}
await CloudRunner.setupSelectedBuildPlatform();
CloudRunner.defaultSecrets = TaskParameterSerializer.readDefaultSecrets();
CloudRunner.cloudRunnerEnvironmentVariables =
TaskParameterSerializer.createCloudRunnerEnvironmentVariables(buildParameters);
if (GitHub.githubInputEnabled) {
const buildParameterPropertyNames = Object.getOwnPropertyNames(buildParameters);
for (const element of CloudRunner.cloudRunnerEnvironmentVariables) {
// CloudRunnerLogger.log(`Cloud Runner output ${Input.ToEnvVarFormat(element.name)} = ${element.value}`);
core.setOutput(Input.ToEnvVarFormat(element.name), element.value);
}
for (const element of buildParameterPropertyNames) {
// CloudRunnerLogger.log(`Cloud Runner output ${Input.ToEnvVarFormat(element)} = ${buildParameters[element]}`);
core.setOutput(Input.ToEnvVarFormat(element), buildParameters[element]);
}
core.setOutput(
Input.ToEnvVarFormat(`buildArtifact`),
`build-${CloudRunner.buildParameters.buildGuid}.tar${
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
}`,
);
}
FollowLogStreamService.Reset();
}
private static async setupSelectedBuildPlatform() {
CloudRunnerLogger.log(`Cloud Runner platform selected ${CloudRunner.buildParameters.providerStrategy}`);
// Detect LocalStack endpoints and reroute AWS provider to local-docker for CI tests that only need S3
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;
if (provider === 'aws' && isLocalStack) {
CloudRunnerLogger.log('LocalStack endpoints detected; routing provider to local-docker for this run');
provider = 'local-docker';
}
switch (provider) {
case 'k8s':
CloudRunner.Provider = new Kubernetes(CloudRunner.buildParameters);
break;
case 'aws':
CloudRunner.Provider = new AwsBuildPlatform(CloudRunner.buildParameters);
break;
case 'test':
CloudRunner.Provider = new TestCloudRunner();
break;
case 'local-docker':
CloudRunner.Provider = new LocalDockerCloudRunner();
break;
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;
}
}
static async run(buildParameters: BuildParameters, baseImage: string) {
if (baseImage.includes(`undefined`)) {
throw new Error(`baseImage is undefined`);
}
await CloudRunner.setup(buildParameters);
await CloudRunner.Provider.setupWorkflow(
CloudRunner.buildParameters.buildGuid,
CloudRunner.buildParameters,
CloudRunner.buildParameters.branch,
CloudRunner.defaultSecrets,
);
try {
if (buildParameters.maxRetainedWorkspaces > 0) {
CloudRunner.lockedWorkspace = SharedWorkspaceLocking.NewWorkspaceName();
const result = await SharedWorkspaceLocking.GetLockedWorkspace(
CloudRunner.lockedWorkspace,
CloudRunner.buildParameters.buildGuid,
CloudRunner.buildParameters,
);
if (result) {
CloudRunnerLogger.logLine(`Using retained workspace ${CloudRunner.lockedWorkspace}`);
CloudRunner.cloudRunnerEnvironmentVariables = [
...CloudRunner.cloudRunnerEnvironmentVariables,
{ name: `LOCKED_WORKSPACE`, value: CloudRunner.lockedWorkspace },
];
} else {
CloudRunnerLogger.log(`Max retained workspaces reached ${buildParameters.maxRetainedWorkspaces}`);
buildParameters.maxRetainedWorkspaces = 0;
CloudRunner.lockedWorkspace = ``;
}
}
await CloudRunner.updateStatusWithBuildParameters();
const output = await new WorkflowCompositionRoot().run(
new CloudRunnerStepParameters(
baseImage,
CloudRunner.cloudRunnerEnvironmentVariables,
CloudRunner.defaultSecrets,
),
);
await CloudRunner.Provider.cleanupWorkflow(
CloudRunner.buildParameters,
CloudRunner.buildParameters.branch,
CloudRunner.defaultSecrets,
);
if (!CloudRunner.buildParameters.isCliMode) core.endGroup();
if (buildParameters.asyncWorkflow && this.isCloudRunnerEnvironment && this.isCloudRunnerAsyncEnvironment) {
await GitHub.updateGitHubCheck(CloudRunner.buildParameters.buildGuid, `success`, `success`, `completed`);
}
if (BuildParameters.shouldUseRetainedWorkspaceMode(buildParameters)) {
const workspace = CloudRunner.lockedWorkspace || ``;
await SharedWorkspaceLocking.ReleaseWorkspace(
workspace,
CloudRunner.buildParameters.buildGuid,
CloudRunner.buildParameters,
);
const isLocked = await SharedWorkspaceLocking.IsWorkspaceLocked(workspace, CloudRunner.buildParameters);
if (isLocked) {
throw new Error(
`still locked after releasing ${await SharedWorkspaceLocking.GetAllLocksForWorkspace(
workspace,
buildParameters,
)}`,
);
}
CloudRunner.lockedWorkspace = ``;
}
await GitHub.triggerWorkflowOnComplete(CloudRunner.buildParameters.finalHooks);
if (buildParameters.constantGarbageCollection) {
CloudRunner.Provider.garbageCollect(``, true, buildParameters.garbageMaxAge, true, true);
}
return new CloudRunnerResult(buildParameters, output, true, true, false);
} catch (error: any) {
CloudRunnerLogger.log(JSON.stringify(error, undefined, 4));
await GitHub.updateGitHubCheck(
CloudRunner.buildParameters.buildGuid,
`Failed - Error ${error?.message || error}`,
`failure`,
`completed`,
);
if (!CloudRunner.buildParameters.isCliMode) core.endGroup();
await CloudRunnerError.handleException(error, CloudRunner.buildParameters, CloudRunner.defaultSecrets);
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);
}
}
export default CloudRunner;
@@ -0,0 +1,15 @@
import CloudRunnerLogger from '../services/core/cloud-runner-logger';
import * as core from '@actions/core';
import CloudRunner from '../cloud-runner';
import CloudRunnerSecret from '../options/cloud-runner-secret';
import BuildParameters from '../../build-parameters';
export class CloudRunnerError {
public static async handleException(error: unknown, buildParameters: BuildParameters, secrets: CloudRunnerSecret[]) {
CloudRunnerLogger.error(JSON.stringify(error, undefined, 4));
core.setFailed('Cloud Runner failed');
if (CloudRunner.Provider !== undefined) {
await CloudRunner.Provider.cleanupWorkflow(buildParameters, buildParameters.branch, secrets);
}
}
}
@@ -0,0 +1,4 @@
class CloudRunnerConstants {
static alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
}
export default CloudRunnerConstants;
@@ -0,0 +1,5 @@
class CloudRunnerEnvironmentVariable {
public name!: string;
public value!: string;
}
export default CloudRunnerEnvironmentVariable;
@@ -0,0 +1,90 @@
import path from 'node:path';
import CloudRunnerOptions from './cloud-runner-options';
import CloudRunner from '../cloud-runner';
import BuildParameters from '../../build-parameters';
export class CloudRunnerFolders {
public static readonly repositoryFolder = 'repo';
public static ToLinuxFolder(folder: string) {
return folder.replace(/\\/g, `/`);
}
// Only the following paths that do not start a path.join with another "Full" suffixed property need to start with an absolute /
public static get uniqueCloudRunnerJobFolderAbsolute(): string {
return CloudRunner.buildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(CloudRunner.buildParameters)
? path.join(`/`, CloudRunnerFolders.buildVolumeFolder, CloudRunner.lockedWorkspace)
: path.join(`/`, CloudRunnerFolders.buildVolumeFolder, CloudRunner.buildParameters.buildGuid);
}
public static get cacheFolderForAllFull(): string {
return path.join('/', CloudRunnerFolders.buildVolumeFolder, CloudRunnerFolders.cacheFolder);
}
public static get cacheFolderForCacheKeyFull(): string {
return path.join(
'/',
CloudRunnerFolders.buildVolumeFolder,
CloudRunnerFolders.cacheFolder,
CloudRunner.buildParameters.cacheKey,
);
}
public static get builderPathAbsolute(): string {
return path.join(
CloudRunnerOptions.useSharedBuilder
? `/${CloudRunnerFolders.buildVolumeFolder}`
: CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute,
`builder`,
);
}
public static get repoPathAbsolute(): string {
return path.join(CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute, CloudRunnerFolders.repositoryFolder);
}
public static get projectPathAbsolute(): string {
return path.join(CloudRunnerFolders.repoPathAbsolute, CloudRunner.buildParameters.projectPath);
}
public static get libraryFolderAbsolute(): string {
return path.join(CloudRunnerFolders.projectPathAbsolute, `Library`);
}
public static get projectBuildFolderAbsolute(): string {
return path.join(CloudRunnerFolders.repoPathAbsolute, CloudRunner.buildParameters.buildPath);
}
public static get lfsFolderAbsolute(): string {
return path.join(CloudRunnerFolders.repoPathAbsolute, `.git`, `lfs`);
}
public static get purgeRemoteCaching(): boolean {
return process.env.PURGE_REMOTE_BUILDER_CACHE !== undefined;
}
public static get lfsCacheFolderFull() {
return path.join(CloudRunnerFolders.cacheFolderForCacheKeyFull, `lfs`);
}
public static get libraryCacheFolderFull() {
return path.join(CloudRunnerFolders.cacheFolderForCacheKeyFull, `Library`);
}
public static get unityBuilderRepoUrl(): string {
return `https://${CloudRunner.buildParameters.gitPrivateToken}@github.com/game-ci/unity-builder.git`;
}
public static get targetBuildRepoUrl(): string {
return `https://${CloudRunner.buildParameters.gitPrivateToken}@github.com/${CloudRunner.buildParameters.githubRepo}.git`;
}
public static get buildVolumeFolder() {
return 'data';
}
public static get cacheFolder() {
return 'cache';
}
}
@@ -1,11 +1,11 @@
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import OrchestratorConstants from './orchestrator-constants'; import CloudRunnerConstants from './cloud-runner-constants';
class OrchestratorNamespace { class CloudRunnerNamespace {
static generateGuid(runNumber: string | number, platform: string) { static generateGuid(runNumber: string | number, platform: string) {
const nanoid = customAlphabet(OrchestratorConstants.alphabet, 4); const nanoid = customAlphabet(CloudRunnerConstants.alphabet, 4);
return `${runNumber}-${platform.toLowerCase().replace('standalone', '')}-${nanoid()}`; return `${runNumber}-${platform.toLowerCase().replace('standalone', '')}-${nanoid()}`;
} }
} }
export default OrchestratorNamespace; export default CloudRunnerNamespace;
@@ -0,0 +1,10 @@
import Input from '../../input';
import CloudRunnerOptions from './cloud-runner-options';
class CloudRunnerOptionsReader {
static GetProperties() {
return [...Object.getOwnPropertyNames(Input), ...Object.getOwnPropertyNames(CloudRunnerOptions)];
}
}
export default CloudRunnerOptionsReader;
@@ -0,0 +1,319 @@
import { Cli } from '../../cli/cli';
import CloudRunnerQueryOverride from './cloud-runner-query-override';
import GitHub from '../../github';
import * as core from '@actions/core';
class CloudRunnerOptions {
// ### ### ###
// Input Handling
// ### ### ###
public static getInput(query: string): string | undefined {
if (GitHub.githubInputEnabled) {
const coreInput = core.getInput(query);
if (coreInput && coreInput !== '') {
return coreInput;
}
}
const alternativeQuery = CloudRunnerOptions.ToEnvVarFormat(query);
// Query input sources
if (Cli.query(query, alternativeQuery)) {
return Cli.query(query, alternativeQuery);
}
if (CloudRunnerQueryOverride.query(query, alternativeQuery)) {
return CloudRunnerQueryOverride.query(query, alternativeQuery);
}
if (process.env[query] !== undefined) {
return process.env[query];
}
if (alternativeQuery !== query && process.env[alternativeQuery] !== undefined) {
return process.env[alternativeQuery];
}
}
public static ToEnvVarFormat(input: string): string {
if (input.toUpperCase() === input) {
return input;
}
return input
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase()
.replace(/ /g, '_');
}
// ### ### ###
// Provider parameters
// ### ### ###
static get region(): string {
return CloudRunnerOptions.getInput('region') || 'eu-west-2';
}
// ### ### ###
// GitHub parameters
// ### ### ###
static get githubChecks(): boolean {
const value = CloudRunnerOptions.getInput('githubChecks');
return value === `true` || false;
}
static get githubCheckId(): string {
return CloudRunnerOptions.getInput('githubCheckId') || ``;
}
static get githubOwner(): string {
return CloudRunnerOptions.getInput('githubOwner') || CloudRunnerOptions.githubRepo?.split(`/`)[0] || '';
}
static get githubRepoName(): string {
return CloudRunnerOptions.getInput('githubRepoName') || CloudRunnerOptions.githubRepo?.split(`/`)[1] || '';
}
static get finalHooks(): string[] {
return CloudRunnerOptions.getInput('finalHooks')?.split(',') || [];
}
// ### ### ###
// Git syncronization parameters
// ### ### ###
static get githubRepo(): string | undefined {
return CloudRunnerOptions.getInput('GITHUB_REPOSITORY') || CloudRunnerOptions.getInput('GITHUB_REPO') || undefined;
}
static get branch(): string {
if (CloudRunnerOptions.getInput(`GITHUB_REF`)) {
return (
CloudRunnerOptions.getInput(`GITHUB_REF`)?.replace('refs/', '').replace(`head/`, '').replace(`heads/`, '') || ``
);
} else if (CloudRunnerOptions.getInput('branch')) {
return CloudRunnerOptions.getInput('branch') || ``;
} else {
return '';
}
}
// ### ### ###
// Cloud Runner parameters
// ### ### ###
static get buildPlatform(): string {
const input = CloudRunnerOptions.getInput('buildPlatform');
if (input && input !== '') {
return input;
}
if (CloudRunnerOptions.providerStrategy !== 'local') {
return 'linux';
}
return process.platform;
}
static get cloudRunnerBranch(): string {
return CloudRunnerOptions.getInput('cloudRunnerBranch') || 'main';
}
static get providerStrategy(): string {
const provider =
CloudRunnerOptions.getInput('cloudRunnerCluster') || CloudRunnerOptions.getInput('providerStrategy');
if (Cli.isCliMode) {
return provider || 'aws';
}
return provider || 'local';
}
static get containerCpu(): string {
return CloudRunnerOptions.getInput('containerCpu') || `1024`;
}
static get containerMemory(): string {
return CloudRunnerOptions.getInput('containerMemory') || `3072`;
}
static get customJob(): string {
return CloudRunnerOptions.getInput('customJob') || '';
}
// ### ### ###
// Custom commands from files parameters
// ### ### ###
static get containerHookFiles(): string[] {
return CloudRunnerOptions.getInput('containerHookFiles')?.split(`,`) || [];
}
static get commandHookFiles(): string[] {
return CloudRunnerOptions.getInput('commandHookFiles')?.split(`,`) || [];
}
// ### ### ###
// Custom commands from yaml parameters
// ### ### ###
static get commandHooks(): string {
return CloudRunnerOptions.getInput('commandHooks') || '';
}
static get postBuildContainerHooks(): string {
return CloudRunnerOptions.getInput('postBuildContainerHooks') || '';
}
static get preBuildContainerHooks(): string {
return CloudRunnerOptions.getInput('preBuildContainerHooks') || '';
}
// ### ### ###
// Input override handling
// ### ### ###
static get pullInputList(): string[] {
return CloudRunnerOptions.getInput('pullInputList')?.split(`,`) || [];
}
static get inputPullCommand(): string {
const value = CloudRunnerOptions.getInput('inputPullCommand');
if (value === 'gcp-secret-manager') {
return 'gcloud secrets versions access 1 --secret="{0}"';
} else if (value === 'aws-secret-manager') {
return 'aws secretsmanager get-secret-value --secret-id {0}';
}
return value || '';
}
// ### ### ###
// Aws
// ### ### ###
static get awsStackName() {
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
// ### ### ###
static get kubeConfig(): string {
return CloudRunnerOptions.getInput('kubeConfig') || '';
}
static get kubeVolume(): string {
return CloudRunnerOptions.getInput('kubeVolume') || '';
}
static get kubeVolumeSize(): string {
return CloudRunnerOptions.getInput('kubeVolumeSize') || '25Gi';
}
static get kubeStorageClass(): string {
return CloudRunnerOptions.getInput('kubeStorageClass') || '';
}
// ### ### ###
// Caching
// ### ### ###
static get cacheKey(): string {
return CloudRunnerOptions.getInput('cacheKey') || CloudRunnerOptions.branch;
}
// ### ### ###
// Utility Parameters
// ### ### ###
static get cloudRunnerDebug(): boolean {
return (
CloudRunnerOptions.getInput(`cloudRunnerTests`) === `true` ||
CloudRunnerOptions.getInput(`cloudRunnerDebug`) === `true` ||
CloudRunnerOptions.getInput(`cloudRunnerDebugTree`) === `true` ||
CloudRunnerOptions.getInput(`cloudRunnerDebugEnv`) === `true` ||
false
);
}
static get skipLfs(): boolean {
return CloudRunnerOptions.getInput(`skipLfs`) === `true`;
}
static get skipCache(): boolean {
return CloudRunnerOptions.getInput(`skipCache`) === `true`;
}
public static get asyncCloudRunner(): boolean {
return CloudRunnerOptions.getInput('asyncCloudRunner') === 'true';
}
public static get useLargePackages(): boolean {
return CloudRunnerOptions.getInput(`useLargePackages`) === `true`;
}
public static get useSharedBuilder(): boolean {
return CloudRunnerOptions.getInput(`useSharedBuilder`) === `true`;
}
public static get useCompressionStrategy(): boolean {
return CloudRunnerOptions.getInput(`useCompressionStrategy`) === `true`;
}
public static get useCleanupCron(): boolean {
return (CloudRunnerOptions.getInput(`useCleanupCron`) || 'true') === 'true';
}
// ### ### ###
// Retained Workspace
// ### ### ###
public static get maxRetainedWorkspaces(): string {
return CloudRunnerOptions.getInput(`maxRetainedWorkspaces`) || `0`;
}
// ### ### ###
// Garbage Collection
// ### ### ###
static get garbageMaxAge(): number {
return Number(CloudRunnerOptions.getInput(`garbageMaxAge`)) || 24;
}
}
export default CloudRunnerOptions;
@@ -0,0 +1,67 @@
import Input from '../../input';
import { GenericInputReader } from '../../input-readers/generic-input-reader';
import CloudRunnerOptions from './cloud-runner-options';
const formatFunction = (value: string, arguments_: any[]) => {
for (const element of arguments_) {
value = value.replace(`{${element.key}}`, element.value);
}
return value;
};
class CloudRunnerQueryOverride {
static queryOverrides: { [key: string]: string } | undefined;
// TODO accept premade secret sources or custom secret source definition yamls
public static query(key: string, alternativeKey: string) {
if (CloudRunnerQueryOverride.queryOverrides && CloudRunnerQueryOverride.queryOverrides[key] !== undefined) {
return CloudRunnerQueryOverride.queryOverrides[key];
}
if (
CloudRunnerQueryOverride.queryOverrides &&
alternativeKey &&
CloudRunnerQueryOverride.queryOverrides[alternativeKey] !== undefined
) {
return CloudRunnerQueryOverride.queryOverrides[alternativeKey];
}
return;
}
private static shouldUseOverride(query: string) {
if (CloudRunnerOptions.inputPullCommand !== '') {
if (CloudRunnerOptions.pullInputList.length > 0) {
const doesInclude =
CloudRunnerOptions.pullInputList.includes(query) ||
CloudRunnerOptions.pullInputList.includes(Input.ToEnvVarFormat(query));
return doesInclude ? true : false;
} else {
return true;
}
}
}
private static async queryOverride(query: string) {
if (!this.shouldUseOverride(query)) {
throw new Error(`Should not be trying to run override query on ${query}`);
}
return await GenericInputReader.Run(
formatFunction(CloudRunnerOptions.inputPullCommand, [{ key: 0, value: query }]),
);
}
public static async PopulateQueryOverrideInput() {
const queries = CloudRunnerOptions.pullInputList;
CloudRunnerQueryOverride.queryOverrides = {};
for (const element of queries) {
if (CloudRunnerQueryOverride.shouldUseOverride(element)) {
CloudRunnerQueryOverride.queryOverrides[element] = await CloudRunnerQueryOverride.queryOverride(element);
}
}
}
}
export default CloudRunnerQueryOverride;
@@ -1,6 +1,6 @@
class OrchestratorSecret { class CloudRunnerSecret {
public ParameterKey!: string; public ParameterKey!: string;
public EnvironmentVariable!: string; public EnvironmentVariable!: string;
public ParameterValue!: string; public ParameterValue!: string;
} }
export default OrchestratorSecret; export default CloudRunnerSecret;
@@ -0,0 +1,3 @@
export class CloudRunnerStatics {
public static readonly logPrefix = `Cloud-Runner`;
}
@@ -0,0 +1,13 @@
import CloudRunnerEnvironmentVariable from './cloud-runner-environment-variable';
import CloudRunnerSecret from './cloud-runner-secret';
export class CloudRunnerStepParameters {
public image: string;
public environment: CloudRunnerEnvironmentVariable[];
public secrets: CloudRunnerSecret[];
constructor(image: string, environmentVariables: CloudRunnerEnvironmentVariable[], secrets: CloudRunnerSecret[]) {
this.image = image;
this.environment = environmentVariables;
this.secrets = secrets;
}
}
@@ -1,14 +1,16 @@
# Provider Loader Dynamic Imports # Provider Loader Dynamic Imports
The provider loader now supports dynamic loading of providers from multiple sources including local file paths, GitHub
repositories, and NPM packages.
## What is a Provider? ## What is a Provider?
A **provider** is a pluggable backend that Orchestrator 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/orchestrator/providers/provider-interface.ts), which defines the common lifecycle methods (setup, run, cleanup, garbage collection, etc.). 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`, which defines the common lifecycle methods (setup,
run, cleanup, garbage collection, etc.).
This abstraction makes Orchestrator 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. 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 ## Features
@@ -45,21 +47,18 @@ const absoluteProvider = await ProviderLoader.loadProvider('/path/to/provider',
```typescript ```typescript
// Load from GitHub URL // Load from GitHub URL
const githubProvider = await ProviderLoader.loadProvider( const githubProvider = await ProviderLoader.loadProvider('https://github.com/user/my-provider', buildParameters);
'https://github.com/user/my-provider',
buildParameters
);
// Load from specific branch // Load from specific branch
const branchProvider = await ProviderLoader.loadProvider( const branchProvider = await ProviderLoader.loadProvider(
'https://github.com/user/my-provider/tree/develop', 'https://github.com/user/my-provider/tree/develop',
buildParameters buildParameters,
); );
// Load from specific path in repository // Load from specific path in repository
const pathProvider = await ProviderLoader.loadProvider( const pathProvider = await ProviderLoader.loadProvider(
'https://github.com/user/my-provider/tree/main/src/providers', 'https://github.com/user/my-provider/tree/main/src/providers',
buildParameters buildParameters,
); );
// Shorthand notation // Shorthand notation
@@ -84,8 +83,20 @@ All providers must implement the `ProviderInterface`:
```typescript ```typescript
interface ProviderInterface { interface ProviderInterface {
cleanupWorkflow(): Promise<void>; cleanupWorkflow(): Promise<void>;
setupWorkflow(buildGuid: string, buildParameters: BuildParameters, branchName: string, defaultSecretsArray: any[]): Promise<void>; setupWorkflow(
runTaskInWorkflow(buildGuid: string, task: string, workingDirectory: string, buildVolumeFolder: string, environmentVariables: any[], secrets: any[]): Promise<string>; 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>; garbageCollect(): Promise<void>;
listResources(): Promise<ProviderResource[]>; listResources(): Promise<ProviderResource[]>;
listWorkflow(): Promise<ProviderWorkflow[]>; listWorkflow(): Promise<ProviderWorkflow[]>;
@@ -107,11 +118,23 @@ export default class MyProvider implements ProviderInterface {
// Cleanup logic // Cleanup logic
} }
async setupWorkflow(buildGuid: string, buildParameters: BuildParameters, branchName: string, defaultSecretsArray: any[]): Promise<void> { async setupWorkflow(
buildGuid: string,
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: any[],
): Promise<void> {
// Setup logic // Setup logic
} }
async runTaskInWorkflow(buildGuid: string, task: string, workingDirectory: string, buildVolumeFolder: string, environmentVariables: any[], secrets: any[]): Promise<string> { async runTaskInWorkflow(
buildGuid: string,
task: string,
workingDirectory: string,
buildVolumeFolder: string,
environmentVariables: any[],
secrets: any[],
): Promise<string> {
// Task execution logic // Task execution logic
return 'Task completed'; return 'Task completed';
} }
@@ -167,6 +190,7 @@ console.log(providers); // ['aws', 'k8s', 'test', 'local-docker', 'local-system'
## Supported URL Formats ## Supported URL Formats
### GitHub URLs ### GitHub URLs
- `https://github.com/user/repo` - `https://github.com/user/repo`
- `https://github.com/user/repo.git` - `https://github.com/user/repo.git`
- `https://github.com/user/repo/tree/branch` - `https://github.com/user/repo/tree/branch`
@@ -174,23 +198,27 @@ console.log(providers); // ['aws', 'k8s', 'test', 'local-docker', 'local-system'
- `git@github.com:user/repo.git` - `git@github.com:user/repo.git`
### Shorthand GitHub References ### Shorthand GitHub References
- `user/repo` - `user/repo`
- `user/repo@branch` - `user/repo@branch`
- `user/repo@branch/path/to/provider` - `user/repo@branch/path/to/provider`
### Local Paths ### Local Paths
- `./relative/path` - `./relative/path`
- `../relative/path` - `../relative/path`
- `/absolute/path` - `/absolute/path`
- `C:\\path\\to\\provider` (Windows) - `C:\\path\\to\\provider` (Windows)
### NPM Packages ### NPM Packages
- `package-name` - `package-name`
- `@scope/package-name` - `@scope/package-name`
## Caching ## 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: 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 1. Repositories are only cloned once
2. Updates are checked and applied automatically 2. Updates are checked and applied automatically
@@ -215,7 +243,7 @@ The provider loader can be configured through environment variables:
## Best Practices ## Best Practices
1. **Use specific branches or tags**: Always specify the branch or specific tag when loading from GitHub 1. **Use specific branches or versions**: Always specify the branch or specific tag when loading from GitHub
2. **Implement proper error handling**: Wrap provider loading in try-catch blocks 2. **Implement proper error handling**: Wrap provider loading in try-catch blocks
3. **Clean up regularly**: Use the cleanup utility to manage cache size 3. **Clean up regularly**: Use the cleanup utility to manage cache size
4. **Test locally first**: Test providers locally before deploying 4. **Test locally first**: Test providers locally before deploying
@@ -1,18 +1,14 @@
import OrchestratorLogger from '../../services/core/orchestrator-logger'; import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import * as core from '@actions/core'; import * as core from '@actions/core';
import { import {
CloudFormation, CloudFormation,
CreateStackCommand, CreateStackCommand,
// eslint-disable-next-line import/named
CreateStackCommandInput, CreateStackCommandInput,
DescribeStacksCommand, DescribeStacksCommand,
// eslint-disable-next-line import/named
DescribeStacksCommandInput, DescribeStacksCommandInput,
ListStacksCommand, ListStacksCommand,
// eslint-disable-next-line import/named
Parameter, Parameter,
UpdateStackCommand, UpdateStackCommand,
// eslint-disable-next-line import/named
UpdateStackCommandInput, UpdateStackCommandInput,
waitUntilStackCreateComplete, waitUntilStackCreateComplete,
waitUntilStackUpdateComplete, waitUntilStackUpdateComplete,
@@ -20,17 +16,6 @@ import {
import { BaseStackFormation } from './cloud-formations/base-stack-formation'; import { BaseStackFormation } from './cloud-formations/base-stack-formation';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
const DEFAULT_STACK_WAIT_TIME_SECONDS = 600;
function getStackWaitTime(): number {
const overrideValue = Number(process.env.ORCHESTRATOR_AWS_STACK_WAIT_TIME ?? '');
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}
export class AWSBaseStack { export class AWSBaseStack {
constructor(baseStackName: string) { constructor(baseStackName: string) {
this.baseStackName = baseStackName; this.baseStackName = baseStackName;
@@ -39,7 +24,6 @@ export class AWSBaseStack {
async setupBaseStack(CF: CloudFormation) { async setupBaseStack(CF: CloudFormation) {
const baseStackName = this.baseStackName; const baseStackName = this.baseStackName;
const stackWaitTimeSeconds = getStackWaitTime();
const baseStack = BaseStackFormation.formation; const baseStack = BaseStackFormation.formation;
@@ -70,39 +54,18 @@ export class AWSBaseStack {
}; };
const stacks = await CF.send( const stacks = await CF.send(
new ListStacksCommand({ new ListStacksCommand({ StackStatusFilter: ['UPDATE_COMPLETE', 'CREATE_COMPLETE', 'ROLLBACK_COMPLETE'] }),
StackStatusFilter: [
'CREATE_IN_PROGRESS',
'UPDATE_IN_PROGRESS',
'UPDATE_COMPLETE',
'CREATE_COMPLETE',
'ROLLBACK_COMPLETE',
],
}),
); );
const stackNames = stacks.StackSummaries?.map((x) => x.StackName) || []; const stackNames = stacks.StackSummaries?.map((x) => x.StackName) || [];
const stackExists: boolean = stackNames.includes(baseStackName); const stackExists: Boolean = stackNames.includes(baseStackName) || false;
const describeStack = async () => { const describeStack = async () => {
return await CF.send(new DescribeStacksCommand(describeStackInput)); return await CF.send(new DescribeStacksCommand(describeStackInput));
}; };
try { try {
if (!stackExists) { if (!stackExists) {
OrchestratorLogger.log(`${baseStackName} stack does not exist (${JSON.stringify(stackNames)})`); CloudRunnerLogger.log(`${baseStackName} stack does not exist (${JSON.stringify(stackNames)})`);
let created = false; await CF.send(new CreateStackCommand(createStackInput));
try { CloudRunnerLogger.log(`created stack (version: ${parametersHash})`);
await CF.send(new CreateStackCommand(createStackInput));
created = true;
} catch (error: any) {
const message = `${error?.name ?? ''} ${error?.message ?? ''}`;
if (message.includes('AlreadyExistsException')) {
OrchestratorLogger.log(`Base stack already exists, continuing with describe`);
} else {
throw error;
}
}
if (created) {
OrchestratorLogger.log(`created stack (version: ${parametersHash})`);
}
} }
const CFState = await describeStack(); const CFState = await describeStack();
let stack = CFState.Stacks?.[0]; let stack = CFState.Stacks?.[0];
@@ -112,35 +75,32 @@ export class AWSBaseStack {
const stackVersion = stack.Parameters?.find((x) => x.ParameterKey === 'Version')?.ParameterValue; const stackVersion = stack.Parameters?.find((x) => x.ParameterKey === 'Version')?.ParameterValue;
if (stack.StackStatus === 'CREATE_IN_PROGRESS') { if (stack.StackStatus === 'CREATE_IN_PROGRESS') {
OrchestratorLogger.log(
`Waiting up to ${stackWaitTimeSeconds}s for '${baseStackName}' CloudFormation creation to finish`,
);
await waitUntilStackCreateComplete( await waitUntilStackCreateComplete(
{ {
client: CF, client: CF,
maxWaitTime: stackWaitTimeSeconds, maxWaitTime: 200,
}, },
describeStackInput, describeStackInput,
); );
} }
if (stackExists) { if (stackExists) {
OrchestratorLogger.log(`Base stack exists (version: ${stackVersion}, local version: ${parametersHash})`); CloudRunnerLogger.log(`Base stack exists (version: ${stackVersion}, local version: ${parametersHash})`);
if (parametersHash !== stackVersion) { if (parametersHash !== stackVersion) {
OrchestratorLogger.log(`Attempting update of base stack`); CloudRunnerLogger.log(`Attempting update of base stack`);
try { try {
await CF.send(new UpdateStackCommand(updateInput)); await CF.send(new UpdateStackCommand(updateInput));
} catch (error: any) { } catch (error: any) {
if (error['message'].includes('No updates are to be performed')) { if (error['message'].includes('No updates are to be performed')) {
OrchestratorLogger.log(`No updates are to be performed`); CloudRunnerLogger.log(`No updates are to be performed`);
} else { } else {
OrchestratorLogger.log(`Update Failed (Stack name: ${baseStackName})`); CloudRunnerLogger.log(`Update Failed (Stack name: ${baseStackName})`);
OrchestratorLogger.log(error['message']); CloudRunnerLogger.log(error['message']);
} }
OrchestratorLogger.log(`Continuing...`); CloudRunnerLogger.log(`Continuing...`);
} }
} else { } else {
OrchestratorLogger.log(`No update required`); CloudRunnerLogger.log(`No update required`);
} }
stack = (await describeStack()).Stacks?.[0]; stack = (await describeStack()).Stacks?.[0];
if (!stack) { if (!stack) {
@@ -149,19 +109,16 @@ export class AWSBaseStack {
); );
} }
if (stack.StackStatus === 'UPDATE_IN_PROGRESS') { if (stack.StackStatus === 'UPDATE_IN_PROGRESS') {
OrchestratorLogger.log(
`Waiting up to ${stackWaitTimeSeconds}s for '${baseStackName}' CloudFormation update to finish`,
);
await waitUntilStackUpdateComplete( await waitUntilStackUpdateComplete(
{ {
client: CF, client: CF,
maxWaitTime: stackWaitTimeSeconds, maxWaitTime: 200,
}, },
describeStackInput, describeStackInput,
); );
} }
} }
OrchestratorLogger.log('base stack is now ready'); CloudRunnerLogger.log('base stack is now ready');
} catch (error) { } catch (error) {
core.error(JSON.stringify(await describeStack(), undefined, 4)); core.error(JSON.stringify(await describeStack(), undefined, 4));
throw error; throw error;
@@ -4,7 +4,7 @@ import { Kinesis } from '@aws-sdk/client-kinesis';
import { CloudWatchLogs } from '@aws-sdk/client-cloudwatch-logs'; import { CloudWatchLogs } from '@aws-sdk/client-cloudwatch-logs';
import { S3 } from '@aws-sdk/client-s3'; import { S3 } from '@aws-sdk/client-s3';
import { Input } from '../../..'; import { Input } from '../../..';
import OrchestratorOptions from '../../options/orchestrator-options'; import CloudRunnerOptions from '../../options/cloud-runner-options';
export class AwsClientFactory { export class AwsClientFactory {
private static cloudFormation: CloudFormation; private static cloudFormation: CloudFormation;
@@ -13,29 +13,11 @@ export class AwsClientFactory {
private static cloudWatchLogs: CloudWatchLogs; private static cloudWatchLogs: CloudWatchLogs;
private static s3: S3; 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 { static getCloudFormation(): CloudFormation {
if (!this.cloudFormation) { if (!this.cloudFormation) {
this.cloudFormation = new CloudFormation({ this.cloudFormation = new CloudFormation({
region: Input.region, region: Input.region,
endpoint: OrchestratorOptions.awsCloudFormationEndpoint, endpoint: CloudRunnerOptions.awsCloudFormationEndpoint,
credentials: AwsClientFactory.getCredentials(),
}); });
} }
@@ -46,8 +28,7 @@ export class AwsClientFactory {
if (!this.ecs) { if (!this.ecs) {
this.ecs = new ECS({ this.ecs = new ECS({
region: Input.region, region: Input.region,
endpoint: OrchestratorOptions.awsEcsEndpoint, endpoint: CloudRunnerOptions.awsEcsEndpoint,
credentials: AwsClientFactory.getCredentials(),
}); });
} }
@@ -58,8 +39,7 @@ export class AwsClientFactory {
if (!this.kinesis) { if (!this.kinesis) {
this.kinesis = new Kinesis({ this.kinesis = new Kinesis({
region: Input.region, region: Input.region,
endpoint: OrchestratorOptions.awsKinesisEndpoint, endpoint: CloudRunnerOptions.awsKinesisEndpoint,
credentials: AwsClientFactory.getCredentials(),
}); });
} }
@@ -70,8 +50,7 @@ export class AwsClientFactory {
if (!this.cloudWatchLogs) { if (!this.cloudWatchLogs) {
this.cloudWatchLogs = new CloudWatchLogs({ this.cloudWatchLogs = new CloudWatchLogs({
region: Input.region, region: Input.region,
endpoint: OrchestratorOptions.awsCloudWatchLogsEndpoint, endpoint: CloudRunnerOptions.awsCloudWatchLogsEndpoint,
credentials: AwsClientFactory.getCredentials(),
}); });
} }
@@ -82,9 +61,8 @@ export class AwsClientFactory {
if (!this.s3) { if (!this.s3) {
this.s3 = new S3({ this.s3 = new S3({
region: Input.region, region: Input.region,
endpoint: OrchestratorOptions.awsS3Endpoint, endpoint: CloudRunnerOptions.awsS3Endpoint,
forcePathStyle: true, forcePathStyle: true,
credentials: AwsClientFactory.getCredentials(),
}); });
} }
@@ -21,7 +21,6 @@ export class AWSCloudFormationTemplates {
public static getSecretDefinitionTemplate(p1: string, p2: string) { public static getSecretDefinitionTemplate(p1: string, p2: string) {
return ` return `
Secrets:
- Name: '${p1}' - Name: '${p1}'
ValueFrom: !Ref ${p2}Secret ValueFrom: !Ref ${p2}Secret
`; `;
@@ -1,16 +1,16 @@
import OrchestratorLogger from '../../services/core/orchestrator-logger'; import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import { CloudFormation, DescribeStackEventsCommand } from '@aws-sdk/client-cloudformation'; import { CloudFormation, DescribeStackEventsCommand } from '@aws-sdk/client-cloudformation';
import * as core from '@actions/core'; import * as core from '@actions/core';
import Orchestrator from '../../orchestrator'; import CloudRunner from '../../cloud-runner';
export class AWSError { export class AWSError {
static async handleStackCreationFailure(error: any, CF: CloudFormation, taskDefStackName: string) { static async handleStackCreationFailure(error: any, CF: CloudFormation, taskDefStackName: string) {
OrchestratorLogger.log('aws error: '); CloudRunnerLogger.log('aws error: ');
core.error(JSON.stringify(error, undefined, 4)); core.error(JSON.stringify(error, undefined, 4));
if (Orchestrator.buildParameters.orchestratorDebug) { if (CloudRunner.buildParameters.cloudRunnerDebug) {
OrchestratorLogger.log('Getting events and resources for task stack'); CloudRunnerLogger.log('Getting events and resources for task stack');
const events = (await CF.send(new DescribeStackEventsCommand({ StackName: taskDefStackName }))).StackEvents; const events = (await CF.send(new DescribeStackEventsCommand({ StackName: taskDefStackName }))).StackEvents;
OrchestratorLogger.log(JSON.stringify(events, undefined, 4)); CloudRunnerLogger.log(JSON.stringify(events, undefined, 4));
} }
} }
} }
@@ -1,34 +1,22 @@
import { import {
CloudFormation, CloudFormation,
CreateStackCommand, CreateStackCommand,
// eslint-disable-next-line import/named
CreateStackCommandInput, CreateStackCommandInput,
DescribeStackResourcesCommand, DescribeStackResourcesCommand,
DescribeStacksCommand, DescribeStacksCommand,
ListStacksCommand, ListStacksCommand,
waitUntilStackCreateComplete, waitUntilStackCreateComplete,
} from '@aws-sdk/client-cloudformation'; } from '@aws-sdk/client-cloudformation';
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def'; import CloudRunnerAWSTaskDef from './cloud-runner-aws-task-def';
import OrchestratorSecret from '../../options/orchestrator-secret'; import CloudRunnerSecret from '../../options/cloud-runner-secret';
import { AWSCloudFormationTemplates } from './aws-cloud-formation-templates'; import { AWSCloudFormationTemplates } from './aws-cloud-formation-templates';
import OrchestratorLogger from '../../services/core/orchestrator-logger'; import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import { AWSError } from './aws-error'; import { AWSError } from './aws-error';
import Orchestrator from '../../orchestrator'; import CloudRunner from '../../cloud-runner';
import { CleanupCronFormation } from './cloud-formations/cleanup-cron-formation'; import { CleanupCronFormation } from './cloud-formations/cleanup-cron-formation';
import OrchestratorOptions from '../../options/orchestrator-options'; import CloudRunnerOptions from '../../options/cloud-runner-options';
import { TaskDefinitionFormation } from './cloud-formations/task-definition-formation'; import { TaskDefinitionFormation } from './cloud-formations/task-definition-formation';
const DEFAULT_STACK_WAIT_TIME_SECONDS = 600;
function getStackWaitTime(): number {
const overrideValue = Number(process.env.ORCHESTRATOR_AWS_STACK_WAIT_TIME ?? '');
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}
export class AWSJobStack { export class AWSJobStack {
private baseStackName: string; private baseStackName: string;
constructor(baseStackName: string) { constructor(baseStackName: string) {
@@ -43,23 +31,23 @@ export class AWSJobStack {
commands: string, commands: string,
mountdir: string, mountdir: string,
workingdir: string, workingdir: string,
secrets: OrchestratorSecret[], secrets: CloudRunnerSecret[],
): Promise<OrchestratorAWSTaskDef> { ): Promise<CloudRunnerAWSTaskDef> {
const taskDefStackName = `${this.baseStackName}-${buildGuid}`; const taskDefStackName = `${this.baseStackName}-${buildGuid}`;
let taskDefCloudFormation = AWSCloudFormationTemplates.readTaskCloudFormationTemplate(); let taskDefCloudFormation = AWSCloudFormationTemplates.readTaskCloudFormationTemplate();
taskDefCloudFormation = taskDefCloudFormation.replace( taskDefCloudFormation = taskDefCloudFormation.replace(
`ContainerCpu: `ContainerCpu:
Default: 1024`, Default: 1024`,
`ContainerCpu: `ContainerCpu:
Default: ${Number.parseInt(Orchestrator.buildParameters.containerCpu)}`, Default: ${Number.parseInt(CloudRunner.buildParameters.containerCpu)}`,
); );
taskDefCloudFormation = taskDefCloudFormation.replace( taskDefCloudFormation = taskDefCloudFormation.replace(
`ContainerMemory: `ContainerMemory:
Default: 2048`, Default: 2048`,
`ContainerMemory: `ContainerMemory:
Default: ${Number.parseInt(Orchestrator.buildParameters.containerMemory)}`, Default: ${Number.parseInt(CloudRunner.buildParameters.containerMemory)}`,
); );
if (!OrchestratorOptions.asyncOrchestrator) { if (!CloudRunnerOptions.asyncCloudRunner) {
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate( taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
taskDefCloudFormation, taskDefCloudFormation,
'# template resources logstream', '# template resources logstream',
@@ -133,8 +121,8 @@ export class AWSJobStack {
}, },
...secretsMappedToCloudFormationParameters, ...secretsMappedToCloudFormationParameters,
]; ];
OrchestratorLogger.log( CloudRunnerLogger.log(
`Starting AWS job with memory: ${Orchestrator.buildParameters.containerMemory} cpu: ${Orchestrator.buildParameters.containerCpu}`, `Starting AWS job with memory: ${CloudRunner.buildParameters.containerMemory} cpu: ${CloudRunner.buildParameters.containerCpu}`,
); );
let previousStackExists = true; let previousStackExists = true;
while (previousStackExists) { while (previousStackExists) {
@@ -147,7 +135,7 @@ export class AWSJobStack {
const element = stacks.StackSummaries[index]; const element = stacks.StackSummaries[index];
if (element.StackName === taskDefStackName && element.StackStatus !== 'DELETE_COMPLETE') { if (element.StackName === taskDefStackName && element.StackStatus !== 'DELETE_COMPLETE') {
previousStackExists = true; previousStackExists = true;
OrchestratorLogger.log(`Previous stack still exists: ${JSON.stringify(element)}`); CloudRunnerLogger.log(`Previous stack still exists: ${JSON.stringify(element)}`);
await new Promise((promise) => setTimeout(promise, 5000)); await new Promise((promise) => setTimeout(promise, 5000));
} }
} }
@@ -159,15 +147,12 @@ export class AWSJobStack {
Parameters: parameters, Parameters: parameters,
}; };
try { try {
const stackWaitTimeSeconds = getStackWaitTime(); CloudRunnerLogger.log(`Creating job aws formation ${taskDefStackName}`);
OrchestratorLogger.log(
`Creating job aws formation ${taskDefStackName} (waiting up to ${stackWaitTimeSeconds}s for completion)`,
);
await CF.send(new CreateStackCommand(createStackInput)); await CF.send(new CreateStackCommand(createStackInput));
await waitUntilStackCreateComplete( await waitUntilStackCreateComplete(
{ {
client: CF, client: CF,
maxWaitTime: stackWaitTimeSeconds, maxWaitTime: 200,
}, },
{ StackName: taskDefStackName }, { StackName: taskDefStackName },
); );
@@ -201,7 +186,7 @@ export class AWSJobStack {
}, },
{ {
ParameterKey: 'BUILDGUID', ParameterKey: 'BUILDGUID',
ParameterValue: Orchestrator.buildParameters.buildGuid, ParameterValue: CloudRunner.buildParameters.buildGuid,
}, },
{ {
ParameterKey: 'EnvironmentName', ParameterKey: 'EnvironmentName',
@@ -209,9 +194,9 @@ export class AWSJobStack {
}, },
], ],
}; };
if (OrchestratorOptions.useCleanupCron) { if (CloudRunnerOptions.useCleanupCron) {
try { try {
OrchestratorLogger.log(`Creating job cleanup formation`); CloudRunnerLogger.log(`Creating job cleanup formation`);
await CF.send(new CreateStackCommand(createCleanupStackInput)); await CF.send(new CreateStackCommand(createCleanupStackInput));
// await CF.waitFor('stackCreateComplete', { StackName: createCleanupStackInput.StackName }).promise(); // await CF.waitFor('stackCreateComplete', { StackName: createCleanupStackInput.StackName }).promise();
@@ -1,61 +1,23 @@
import { DescribeTasksCommand, RunTaskCommand, waitUntilTasksRunning } from '@aws-sdk/client-ecs'; import { DescribeTasksCommand, RunTaskCommand, waitUntilTasksRunning } from '@aws-sdk/client-ecs';
import { DescribeStreamCommand, GetRecordsCommand, GetShardIteratorCommand } from '@aws-sdk/client-kinesis'; import { DescribeStreamCommand, GetRecordsCommand, GetShardIteratorCommand } from '@aws-sdk/client-kinesis';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable'; import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
import * as core from '@actions/core'; import * as core from '@actions/core';
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def'; import CloudRunnerAWSTaskDef from './cloud-runner-aws-task-def';
import * as zlib from 'node:zlib'; import * as zlib from 'node:zlib';
import OrchestratorLogger from '../../services/core/orchestrator-logger'; import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import { Input } from '../../..'; import { Input } from '../../..';
import Orchestrator from '../../orchestrator'; import CloudRunner from '../../cloud-runner';
import { CommandHookService } from '../../services/hooks/command-hook-service'; import { CommandHookService } from '../../services/hooks/command-hook-service';
import { FollowLogStreamService } from '../../services/core/follow-log-stream-service'; import { FollowLogStreamService } from '../../services/core/follow-log-stream-service';
import OrchestratorOptions from '../../options/orchestrator-options'; import CloudRunnerOptions from '../../options/cloud-runner-options';
import GitHub from '../../../github'; import GitHub from '../../../github';
import { AwsClientFactory } from './aws-client-factory'; import { AwsClientFactory } from './aws-client-factory';
class AWSTaskRunner { class AWSTaskRunner {
private static readonly encodedUnderscore = `$252F`; 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: OrchestratorEnvironmentVariable[],
): OrchestratorEnvironmentVariable[] {
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');
OrchestratorLogger.log(`AWS TaskRunner: Replaced localhost with host.docker.internal for ${x.name}: ${value}`);
}
return { name: x.name, value };
});
}
static async runTask( static async runTask(
taskDef: OrchestratorAWSTaskDef, taskDef: CloudRunnerAWSTaskDef,
environment: OrchestratorEnvironmentVariable[], environment: CloudRunnerEnvironmentVariable[],
commands: string, commands: string,
): Promise<{ output: string; shouldCleanup: boolean }> { ): Promise<{ output: string; shouldCleanup: boolean }> {
const cluster = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ECSCluster')?.PhysicalResourceId || ''; const cluster = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ECSCluster')?.PhysicalResourceId || '';
@@ -70,9 +32,6 @@ class AWSTaskRunner {
const streamName = const streamName =
taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'KinesisStream')?.PhysicalResourceId || ''; taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'KinesisStream')?.PhysicalResourceId || '';
// Transform localhost endpoints for container environment
const transformedEnvironment = AWSTaskRunner.transformEndpointsForContainer(environment);
const runParameters = { const runParameters = {
cluster, cluster,
taskDefinition, taskDefinition,
@@ -81,8 +40,8 @@ class AWSTaskRunner {
containerOverrides: [ containerOverrides: [
{ {
name: taskDef.taskDefStackName, name: taskDef.taskDefStackName,
environment: transformedEnvironment, environment,
command: ['-c', CommandHookService.ApplyHooksToCommands(commands, Orchestrator.buildParameters)], command: ['-c', CommandHookService.ApplyHooksToCommands(commands, CloudRunner.buildParameters)],
}, },
], ],
}, },
@@ -97,28 +56,28 @@ class AWSTaskRunner {
}; };
if (JSON.stringify(runParameters.overrides.containerOverrides).length > 8192) { if (JSON.stringify(runParameters.overrides.containerOverrides).length > 8192) {
OrchestratorLogger.log(JSON.stringify(runParameters.overrides.containerOverrides, undefined, 4)); CloudRunnerLogger.log(JSON.stringify(runParameters.overrides.containerOverrides, undefined, 4));
throw new Error(`Container Overrides length must be at most 8192`); 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 AwsClientFactory.getECS().send(new RunTaskCommand(runParameters as any));
const taskArn = task.tasks?.[0].taskArn || ''; const taskArn = task.tasks?.[0].taskArn || '';
OrchestratorLogger.log('Orchestrator job is starting'); CloudRunnerLogger.log('Cloud runner job is starting');
await AWSTaskRunner.waitUntilTaskRunning(taskArn, cluster); await AWSTaskRunner.waitUntilTaskRunning(taskArn, cluster);
OrchestratorLogger.log( CloudRunnerLogger.log(
`Orchestrator job status is running ${(await AWSTaskRunner.describeTasks(cluster, taskArn))?.lastStatus} Async:${ `Cloud runner job status is running ${(await AWSTaskRunner.describeTasks(cluster, taskArn))?.lastStatus} Async:${
OrchestratorOptions.asyncOrchestrator CloudRunnerOptions.asyncCloudRunner
}`, }`,
); );
if (OrchestratorOptions.asyncOrchestrator) { if (CloudRunnerOptions.asyncCloudRunner) {
const shouldCleanup: boolean = false; const shouldCleanup: boolean = false;
const output: string = ''; const output: string = '';
OrchestratorLogger.log(`Watch Orchestrator To End: false`); CloudRunnerLogger.log(`Watch Cloud Runner To End: false`);
return { output, shouldCleanup }; return { output, shouldCleanup };
} }
OrchestratorLogger.log(`Streaming...`); CloudRunnerLogger.log(`Streaming...`);
const { output, shouldCleanup } = await this.streamLogsUntilTaskStops(cluster, taskArn, streamName); const { output, shouldCleanup } = await this.streamLogsUntilTaskStops(cluster, taskArn, streamName);
let exitCode; let exitCode;
let containerState; let containerState;
@@ -133,13 +92,13 @@ class AWSTaskRunner {
containerState = containers[0]; containerState = containers[0];
exitCode = containerState?.exitCode; exitCode = containerState?.exitCode;
} }
OrchestratorLogger.log(`Container State: ${JSON.stringify(containerState, undefined, 4)}`); CloudRunnerLogger.log(`Container State: ${JSON.stringify(containerState, undefined, 4)}`);
if (exitCode === undefined) { if (exitCode === undefined) {
OrchestratorLogger.logWarning(`Undefined exitcode for container`); CloudRunnerLogger.logWarning(`Undefined exitcode for container`);
} }
const wasSuccessful = exitCode === 0; const wasSuccessful = exitCode === 0;
if (wasSuccessful) { if (wasSuccessful) {
OrchestratorLogger.log(`Orchestrator job has finished successfully`); CloudRunnerLogger.log(`Cloud runner job has finished successfully`);
return { output, shouldCleanup }; return { output, shouldCleanup };
} }
@@ -166,7 +125,7 @@ class AWSTaskRunner {
const error = error_ as Error; const error = error_ as Error;
await new Promise((resolve) => setTimeout(resolve, 3000)); await new Promise((resolve) => setTimeout(resolve, 3000));
const taskAfterError = await AWSTaskRunner.describeTasks(cluster, taskArn); const taskAfterError = await AWSTaskRunner.describeTasks(cluster, taskArn);
OrchestratorLogger.log(`Orchestrator job has ended ${taskAfterError?.containers?.[0]?.lastStatus}`); CloudRunnerLogger.log(`Cloud runner job has ended ${taskAfterError?.containers?.[0]?.lastStatus}`);
core.setFailed(error); core.setFailed(error);
core.error(error); core.error(error);
@@ -193,7 +152,7 @@ class AWSTaskRunner {
} }
const jitterMs = Math.floor(Math.random() * Math.min(1000, delayMs)); const jitterMs = Math.floor(Math.random() * Math.min(1000, delayMs));
const sleepMs = delayMs + jitterMs; const sleepMs = delayMs + jitterMs;
OrchestratorLogger.log( CloudRunnerLogger.log(
`AWS throttled DescribeTasks (attempt ${attempt}/${maxAttempts}), backing off ${sleepMs}ms (${delayMs} + jitter ${jitterMs})`, `AWS throttled DescribeTasks (attempt ${attempt}/${maxAttempts}), backing off ${sleepMs}ms (${delayMs} + jitter ${jitterMs})`,
); );
await new Promise((r) => setTimeout(r, sleepMs)); await new Promise((r) => setTimeout(r, sleepMs));
@@ -204,12 +163,12 @@ class AWSTaskRunner {
static async streamLogsUntilTaskStops(clusterName: string, taskArn: string, kinesisStreamName: string) { static async streamLogsUntilTaskStops(clusterName: string, taskArn: string, kinesisStreamName: string) {
await new Promise((resolve) => setTimeout(resolve, 3000)); await new Promise((resolve) => setTimeout(resolve, 3000));
OrchestratorLogger.log(`Streaming...`); CloudRunnerLogger.log(`Streaming...`);
const stream = await AWSTaskRunner.getLogStream(kinesisStreamName); const stream = await AWSTaskRunner.getLogStream(kinesisStreamName);
let iterator = await AWSTaskRunner.getLogIterator(stream); let iterator = await AWSTaskRunner.getLogIterator(stream);
const logBaseUrl = `https://${Input.region}.console.aws.amazon.com/cloudwatch/home?region=${Input.region}#logsV2:log-groups/log-group/${Orchestrator.buildParameters.awsStackName}${AWSTaskRunner.encodedUnderscore}${Orchestrator.buildParameters.awsStackName}-${Orchestrator.buildParameters.buildGuid}`; const logBaseUrl = `https://${Input.region}.console.aws.amazon.com/cloudwatch/home?region=${Input.region}#logsV2:log-groups/log-group/${CloudRunner.buildParameters.awsStackName}${AWSTaskRunner.encodedUnderscore}${CloudRunner.buildParameters.awsStackName}-${CloudRunner.buildParameters.buildGuid}`;
OrchestratorLogger.log(`You view the log stream on AWS Cloud Watch: ${logBaseUrl}`); CloudRunnerLogger.log(`You view the log stream on AWS Cloud Watch: ${logBaseUrl}`);
await GitHub.updateGitHubCheck(`You view the log stream on AWS Cloud Watch: ${logBaseUrl}`, ``); await GitHub.updateGitHubCheck(`You view the log stream on AWS Cloud Watch: ${logBaseUrl}`, ``);
let shouldReadLogs = true; let shouldReadLogs = true;
let shouldCleanup = true; let shouldCleanup = true;
@@ -248,9 +207,8 @@ class AWSTaskRunner {
const baseBackoffMs = 1000; const baseBackoffMs = 1000;
const jitterMs = Math.floor(Math.random() * 1000); const jitterMs = Math.floor(Math.random() * 1000);
const sleepMs = baseBackoffMs + jitterMs; const sleepMs = baseBackoffMs + jitterMs;
OrchestratorLogger.log(`AWS throttled GetRecords, backing off ${sleepMs}ms (1000 + jitter ${jitterMs})`); CloudRunnerLogger.log(`AWS throttled GetRecords, backing off ${sleepMs}ms (1000 + jitter ${jitterMs})`);
await new Promise((r) => setTimeout(r, sleepMs)); await new Promise((r) => setTimeout(r, sleepMs));
return { iterator, shouldReadLogs, output, shouldCleanup }; return { iterator, shouldReadLogs, output, shouldCleanup };
} }
throw error; throw error;
@@ -269,18 +227,18 @@ class AWSTaskRunner {
private static checkStreamingShouldContinue(taskData: any, timestamp: number, shouldReadLogs: boolean) { private static checkStreamingShouldContinue(taskData: any, timestamp: number, shouldReadLogs: boolean) {
if (taskData?.lastStatus === 'UNKNOWN') { if (taskData?.lastStatus === 'UNKNOWN') {
OrchestratorLogger.log('## Orchestrator job unknwon'); CloudRunnerLogger.log('## Cloud runner job unknwon');
} }
if (taskData?.lastStatus !== 'RUNNING') { if (taskData?.lastStatus !== 'RUNNING') {
if (timestamp === 0) { if (timestamp === 0) {
OrchestratorLogger.log('## Orchestrator job stopped, streaming end of logs'); CloudRunnerLogger.log('## Cloud runner job stopped, streaming end of logs');
timestamp = Date.now(); timestamp = Date.now();
} }
if (timestamp !== 0 && Date.now() - timestamp > 30000) { if (timestamp !== 0 && Date.now() - timestamp > 30000) {
OrchestratorLogger.log('## Orchestrator status is not RUNNING for 30 seconds, last query for logs'); CloudRunnerLogger.log('## Cloud runner status is not RUNNING for 30 seconds, last query for logs');
shouldReadLogs = false; shouldReadLogs = false;
} }
OrchestratorLogger.log(`## Status of job: ${taskData.lastStatus}`); CloudRunnerLogger.log(`## Status of job: ${taskData.lastStatus}`);
} }
return { timestamp, shouldReadLogs }; return { timestamp, shouldReadLogs };
@@ -1,7 +1,7 @@
import Orchestrator from '../../../orchestrator'; import CloudRunner from '../../../cloud-runner';
export class TaskDefinitionFormation { export class TaskDefinitionFormation {
public static readonly description: string = `Game CI Orchestrator Task Stack`; public static readonly description: string = `Game CI Cloud Runner Task Stack`;
public static get formation(): string { public static get formation(): string {
return `AWSTemplateFormatVersion: 2010-09-09 return `AWSTemplateFormatVersion: 2010-09-09
Description: ${TaskDefinitionFormation.description} Description: ${TaskDefinitionFormation.description}
@@ -29,11 +29,11 @@ Parameters:
Default: 80 Default: 80
Description: What port number the application inside the docker container is binding to Description: What port number the application inside the docker container is binding to
ContainerCpu: ContainerCpu:
Default: ${Orchestrator.buildParameters.containerCpu} Default: ${CloudRunner.buildParameters.containerCpu}
Type: Number Type: Number
Description: How much CPU to give the container. 1024 is 1 CPU Description: How much CPU to give the container. 1024 is 1 CPU
ContainerMemory: ContainerMemory:
Default: ${Orchestrator.buildParameters.containerMemory} Default: ${CloudRunner.buildParameters.containerMemory}
Type: Number Type: Number
Description: How much memory in megabytes to give the container Description: How much memory in megabytes to give the container
BUILDGUID: BUILDGUID:
@@ -127,7 +127,8 @@ Resources:
- SourceVolume: efs-data - SourceVolume: efs-data
ContainerPath: !Ref EFSMountDirectory ContainerPath: !Ref EFSMountDirectory
ReadOnly: false ReadOnly: false
# template secrets p3 - container def Secrets:
# template secrets p3 - container def
LogConfiguration: LogConfiguration:
LogDriver: awslogs LogDriver: awslogs
Options: Options:
@@ -1,10 +1,9 @@
// eslint-disable-next-line import/named
import { StackResource } from '@aws-sdk/client-cloudformation'; import { StackResource } from '@aws-sdk/client-cloudformation';
class OrchestratorAWSTaskDef { class CloudRunnerAWSTaskDef {
public taskDefStackName!: string; public taskDefStackName!: string;
public taskDefCloudFormation!: string; public taskDefCloudFormation!: string;
public taskDefResources: StackResource[] | undefined; public taskDefResources: StackResource[] | undefined;
public baseResources: StackResource[] | undefined; public baseResources: StackResource[] | undefined;
} }
export default OrchestratorAWSTaskDef; export default CloudRunnerAWSTaskDef;
@@ -1,11 +1,11 @@
import { CloudFormation, DeleteStackCommand, waitUntilStackDeleteComplete } from '@aws-sdk/client-cloudformation'; import { CloudFormation, DeleteStackCommand, waitUntilStackDeleteComplete } from '@aws-sdk/client-cloudformation';
import OrchestratorSecret from '../../options/orchestrator-secret'; import CloudRunnerSecret from '../../options/cloud-runner-secret';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable'; import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def'; import CloudRunnerAWSTaskDef from './cloud-runner-aws-task-def';
import AwsTaskRunner from './aws-task-runner'; import AwsTaskRunner from './aws-task-runner';
import { ProviderInterface } from '../provider-interface'; import { ProviderInterface } from '../provider-interface';
import BuildParameters from '../../../build-parameters'; import BuildParameters from '../../../build-parameters';
import OrchestratorLogger from '../../services/core/orchestrator-logger'; import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import { AWSJobStack as AwsJobStack } from './aws-job-stack'; import { AWSJobStack as AwsJobStack } from './aws-job-stack';
import { AWSBaseStack as AwsBaseStack } from './aws-base-stack'; import { AWSBaseStack as AwsBaseStack } from './aws-base-stack';
import { Input } from '../../..'; import { Input } from '../../..';
@@ -13,20 +13,8 @@ import { GarbageCollectionService } from './services/garbage-collection-service'
import { ProviderResource } from '../provider-resource'; import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow'; import { ProviderWorkflow } from '../provider-workflow';
import { TaskService } from './services/task-service'; import { TaskService } from './services/task-service';
import OrchestratorOptions from '../../options/orchestrator-options'; import CloudRunnerOptions from '../../options/cloud-runner-options';
import { AwsClientFactory } from './aws-client-factory'; 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.ORCHESTRATOR_AWS_STACK_WAIT_TIME ?? '');
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}
class AWSBuildEnvironment implements ProviderInterface { class AWSBuildEnvironment implements ProviderInterface {
private baseStackName: string; private baseStackName: string;
@@ -98,16 +86,14 @@ class AWSBuildEnvironment implements ProviderInterface {
commands: string, commands: string,
mountdir: string, mountdir: string,
workingdir: string, workingdir: string,
environment: OrchestratorEnvironmentVariable[], environment: CloudRunnerEnvironmentVariable[],
secrets: OrchestratorSecret[], secrets: CloudRunnerSecret[],
): Promise<string> { ): Promise<string> {
process.env.AWS_REGION = Input.region; process.env.AWS_REGION = Input.region;
ResourceTracking.logAllocationSummary('aws workflow');
await ResourceTracking.logDiskUsageSnapshot('aws workflow (host)');
AwsClientFactory.getECS(); AwsClientFactory.getECS();
const CF = AwsClientFactory.getCloudFormation(); const CF = AwsClientFactory.getCloudFormation();
AwsClientFactory.getKinesis(); AwsClientFactory.getKinesis();
OrchestratorLogger.log(`AWS Region: ${CF.config.region}`); CloudRunnerLogger.log(`AWS Region: ${CF.config.region}`);
const entrypoint = ['/bin/sh']; const entrypoint = ['/bin/sh'];
const startTimeMs = Date.now(); const startTimeMs = Date.now();
const taskDef = await new AwsJobStack(this.baseStackName).setupCloudFormations( const taskDef = await new AwsJobStack(this.baseStackName).setupCloudFormations(
@@ -124,37 +110,36 @@ class AWSBuildEnvironment implements ProviderInterface {
let postRunTaskTimeMs; let postRunTaskTimeMs;
try { try {
const postSetupStacksTimeMs = Date.now(); const postSetupStacksTimeMs = Date.now();
OrchestratorLogger.log(`Setup job time: ${Math.floor((postSetupStacksTimeMs - startTimeMs) / 1000)}s`); CloudRunnerLogger.log(`Setup job time: ${Math.floor((postSetupStacksTimeMs - startTimeMs) / 1000)}s`);
const { output, shouldCleanup } = await AwsTaskRunner.runTask(taskDef, environment, commands); const { output, shouldCleanup } = await AwsTaskRunner.runTask(taskDef, environment, commands);
postRunTaskTimeMs = Date.now(); postRunTaskTimeMs = Date.now();
OrchestratorLogger.log(`Run job time: ${Math.floor((postRunTaskTimeMs - postSetupStacksTimeMs) / 1000)}s`); CloudRunnerLogger.log(`Run job time: ${Math.floor((postRunTaskTimeMs - postSetupStacksTimeMs) / 1000)}s`);
if (shouldCleanup) { if (shouldCleanup) {
await this.cleanupResources(CF, taskDef); await this.cleanupResources(CF, taskDef);
} }
const postCleanupTimeMs = Date.now(); const postCleanupTimeMs = Date.now();
if (postRunTaskTimeMs !== undefined) if (postRunTaskTimeMs !== undefined)
OrchestratorLogger.log(`Cleanup job time: ${Math.floor((postCleanupTimeMs - postRunTaskTimeMs) / 1000)}s`); CloudRunnerLogger.log(`Cleanup job time: ${Math.floor((postCleanupTimeMs - postRunTaskTimeMs) / 1000)}s`);
return output; return output;
} catch (error) { } catch (error) {
OrchestratorLogger.log(`error running task ${error}`); CloudRunnerLogger.log(`error running task ${error}`);
await this.cleanupResources(CF, taskDef); await this.cleanupResources(CF, taskDef);
throw error; throw error;
} }
} }
async cleanupResources(CF: CloudFormation, taskDef: OrchestratorAWSTaskDef) { async cleanupResources(CF: CloudFormation, taskDef: CloudRunnerAWSTaskDef) {
const stackWaitTimeSeconds = getStackWaitTime(); CloudRunnerLogger.log('Cleanup starting');
OrchestratorLogger.log(`Cleanup starting (waiting up to ${stackWaitTimeSeconds}s for stack deletion)`);
await CF.send(new DeleteStackCommand({ StackName: taskDef.taskDefStackName })); await CF.send(new DeleteStackCommand({ StackName: taskDef.taskDefStackName }));
if (OrchestratorOptions.useCleanupCron) { if (CloudRunnerOptions.useCleanupCron) {
await CF.send(new DeleteStackCommand({ StackName: `${taskDef.taskDefStackName}-cleanup` })); await CF.send(new DeleteStackCommand({ StackName: `${taskDef.taskDefStackName}-cleanup` }));
} }
await waitUntilStackDeleteComplete( await waitUntilStackDeleteComplete(
{ {
client: CF, client: CF,
maxWaitTime: stackWaitTimeSeconds, maxWaitTime: 200,
}, },
{ {
StackName: taskDef.taskDefStackName, StackName: taskDef.taskDefStackName,
@@ -163,14 +148,14 @@ class AWSBuildEnvironment implements ProviderInterface {
await waitUntilStackDeleteComplete( await waitUntilStackDeleteComplete(
{ {
client: CF, client: CF,
maxWaitTime: stackWaitTimeSeconds, maxWaitTime: 200,
}, },
{ {
StackName: `${taskDef.taskDefStackName}-cleanup`, StackName: `${taskDef.taskDefStackName}-cleanup`,
}, },
); );
OrchestratorLogger.log(`Deleted Stack: ${taskDef.taskDefStackName}`); CloudRunnerLogger.log(`Deleted Stack: ${taskDef.taskDefStackName}`);
OrchestratorLogger.log('Cleanup complete'); CloudRunnerLogger.log('Cleanup complete');
} }
} }
export default AWSBuildEnvironment; export default AWSBuildEnvironment;
@@ -2,7 +2,7 @@ import { DeleteStackCommand, DescribeStackResourcesCommand } from '@aws-sdk/clie
import { DeleteLogGroupCommand } from '@aws-sdk/client-cloudwatch-logs'; import { DeleteLogGroupCommand } from '@aws-sdk/client-cloudwatch-logs';
import { StopTaskCommand } from '@aws-sdk/client-ecs'; import { StopTaskCommand } from '@aws-sdk/client-ecs';
import Input from '../../../../input'; import Input from '../../../../input';
import OrchestratorLogger from '../../../services/core/orchestrator-logger'; import CloudRunnerLogger from '../../../services/core/cloud-runner-logger';
import { TaskService } from './task-service'; import { TaskService } from './task-service';
import { AwsClientFactory } from '../aws-client-factory'; import { AwsClientFactory } from '../aws-client-factory';
@@ -25,7 +25,7 @@ export class GarbageCollectionService {
const { taskElement, element } = task; const { taskElement, element } = task;
taskDefinitionsInUse.push(taskElement.taskDefinitionArn); taskDefinitionsInUse.push(taskElement.taskDefinitionArn);
if (deleteResources && (!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(taskElement.createdAt!))) { if (deleteResources && (!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(taskElement.createdAt!))) {
OrchestratorLogger.log(`Stopping task ${taskElement.containers?.[0].name}`); CloudRunnerLogger.log(`Stopping task ${taskElement.containers?.[0].name}`);
await ecs.send(new StopTaskCommand({ task: taskElement.taskArn || '', cluster: element })); await ecs.send(new StopTaskCommand({ task: taskElement.taskArn || '', cluster: element }));
} }
} }
@@ -37,7 +37,7 @@ export class GarbageCollectionService {
(x) => x.ResourceType === 'AWS::ECS::TaskDefinition' && taskDefinitionsInUse.includes(x.PhysicalResourceId), (x) => x.ResourceType === 'AWS::ECS::TaskDefinition' && taskDefinitionsInUse.includes(x.PhysicalResourceId),
) )
) { ) {
OrchestratorLogger.log(`Skipping ${element.StackName} - active task was running not deleting`); CloudRunnerLogger.log(`Skipping ${element.StackName} - active task was running not deleting`);
return; return;
} }
@@ -47,12 +47,12 @@ export class GarbageCollectionService {
(!OneDayOlderOnly || (element.CreationTime && GarbageCollectionService.isOlderThan1day(element.CreationTime))) (!OneDayOlderOnly || (element.CreationTime && GarbageCollectionService.isOlderThan1day(element.CreationTime)))
) { ) {
if (element.StackName === 'game-ci' || element.TemplateDescription === 'Game-CI base stack') { if (element.StackName === 'game-ci' || element.TemplateDescription === 'Game-CI base stack') {
OrchestratorLogger.log(`Skipping ${element.StackName} ignore list`); CloudRunnerLogger.log(`Skipping ${element.StackName} ignore list`);
return; return;
} }
OrchestratorLogger.log(`Deleting ${element.StackName}`); CloudRunnerLogger.log(`Deleting ${element.StackName}`);
await CF.send(new DeleteStackCommand({ StackName: element.StackName })); await CF.send(new DeleteStackCommand({ StackName: element.StackName }));
} }
} }
@@ -62,14 +62,14 @@ export class GarbageCollectionService {
deleteResources && deleteResources &&
(!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(new Date(element.creationTime!))) (!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(new Date(element.creationTime!)))
) { ) {
OrchestratorLogger.log(`Deleting ${element.logGroupName}`); CloudRunnerLogger.log(`Deleting ${element.logGroupName}`);
await cwl.send(new DeleteLogGroupCommand({ logGroupName: element.logGroupName || '' })); await cwl.send(new DeleteLogGroupCommand({ logGroupName: element.logGroupName || '' }));
} }
} }
const locks = await TaskService.getLocks(); const locks = await TaskService.getLocks();
for (const element of locks) { for (const element of locks) {
OrchestratorLogger.log(`Lock: ${element.Key}`); CloudRunnerLogger.log(`Lock: ${element.Key}`);
} }
} }
} }
@@ -3,21 +3,24 @@ import {
DescribeStacksCommand, DescribeStacksCommand,
ListStacksCommand, ListStacksCommand,
} from '@aws-sdk/client-cloudformation'; } from '@aws-sdk/client-cloudformation';
import type { StackSummary } from '@aws-sdk/client-cloudformation'; import type { ListStacksCommandOutput } from '@aws-sdk/client-cloudformation';
// eslint-disable-next-line import/named import { DescribeLogGroupsCommand } from '@aws-sdk/client-cloudwatch-logs';
import { DescribeLogGroupsCommand, DescribeLogGroupsCommandInput } from '@aws-sdk/client-cloudwatch-logs'; import type { DescribeLogGroupsCommandInput, DescribeLogGroupsCommandOutput } 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 { DescribeTasksCommand, ListClustersCommand, ListTasksCommand } from '@aws-sdk/client-ecs';
import type { Task } from '@aws-sdk/client-ecs'; import type { DescribeTasksCommandOutput } from '@aws-sdk/client-ecs';
import { ListObjectsV2Command } from '@aws-sdk/client-s3'; import { ListObjectsCommand } from '@aws-sdk/client-s3';
import Input from '../../../../input'; import Input from '../../../../input';
import OrchestratorLogger from '../../../services/core/orchestrator-logger'; import CloudRunnerLogger from '../../../services/core/cloud-runner-logger';
import { BaseStackFormation } from '../cloud-formations/base-stack-formation'; import { BaseStackFormation } from '../cloud-formations/base-stack-formation';
import AwsTaskRunner from '../aws-task-runner'; import AwsTaskRunner from '../aws-task-runner';
import Orchestrator from '../../../orchestrator'; import CloudRunner from '../../../cloud-runner';
import { AwsClientFactory } from '../aws-client-factory'; import { AwsClientFactory } from '../aws-client-factory';
import SharedWorkspaceLocking from '../../../services/core/shared-workspace-locking'; import SharedWorkspaceLocking from '../../../services/core/shared-workspace-locking';
type StackSummary = NonNullable<ListStacksCommandOutput['StackSummaries']>[number];
type LogGroup = NonNullable<DescribeLogGroupsCommandOutput['logGroups']>[number];
type Task = NonNullable<DescribeTasksCommandOutput['tasks']>[number];
export class TaskService { export class TaskService {
static async watch() { static async watch() {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
@@ -29,10 +32,10 @@ export class TaskService {
return output; return output;
} }
public static async getCloudFormationJobStacks(): Promise<StackSummary[]> { public static async getCloudFormationJobStacks() {
const result: StackSummary[] = []; const result: StackSummary[] = [];
OrchestratorLogger.log(``); CloudRunnerLogger.log(``);
OrchestratorLogger.log(`List Cloud Formation Stacks`); CloudRunnerLogger.log(`List Cloud Formation Stacks`);
process.env.AWS_REGION = Input.region; process.env.AWS_REGION = Input.region;
const CF = AwsClientFactory.getCloudFormation(); const CF = AwsClientFactory.getCloudFormation();
const stacks = const stacks =
@@ -40,16 +43,16 @@ export class TaskService {
(_x) => (_x) =>
_x.StackStatus !== 'DELETE_COMPLETE' && _x.TemplateDescription !== BaseStackFormation.baseStackDecription, _x.StackStatus !== 'DELETE_COMPLETE' && _x.TemplateDescription !== BaseStackFormation.baseStackDecription,
) || []; ) || [];
OrchestratorLogger.log(``); CloudRunnerLogger.log(``);
OrchestratorLogger.log(`Cloud Formation Stacks ${stacks.length}`); CloudRunnerLogger.log(`Cloud Formation Stacks ${stacks.length}`);
for (const element of stacks) { for (const element of stacks) {
if (!element.CreationTime) { if (!element.CreationTime) {
OrchestratorLogger.log(`${element.StackName} due to undefined 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() ?? 0));
OrchestratorLogger.log( CloudRunnerLogger.log(
`Task Stack ${element.StackName} - Age D${Math.floor( `Task Stack ${element.StackName} - Age D${Math.floor(
ageDate.getHours() / 24, ageDate.getHours() / 24,
)} H${ageDate.getHours()} M${ageDate.getMinutes()}`, )} H${ageDate.getHours()} M${ageDate.getMinutes()}`,
@@ -61,73 +64,68 @@ export class TaskService {
(_x) => (_x) =>
_x.StackStatus !== 'DELETE_COMPLETE' && _x.TemplateDescription === BaseStackFormation.baseStackDecription, _x.StackStatus !== 'DELETE_COMPLETE' && _x.TemplateDescription === BaseStackFormation.baseStackDecription,
) || []; ) || [];
OrchestratorLogger.log(``); CloudRunnerLogger.log(``);
OrchestratorLogger.log(`Base Stacks ${baseStacks.length}`); CloudRunnerLogger.log(`Base Stacks ${baseStacks.length}`);
for (const element of baseStacks) { for (const element of baseStacks) {
if (!element.CreationTime) { if (!element.CreationTime) {
OrchestratorLogger.log(`${element.StackName} due to undefined 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() ?? 0));
OrchestratorLogger.log( CloudRunnerLogger.log(
`Task Stack ${element.StackName} - Age D${Math.floor( `Task Stack ${element.StackName} - Age D${Math.floor(
ageDate.getHours() / 24, ageDate.getHours() / 24,
)} H${ageDate.getHours()} M${ageDate.getMinutes()}`, )} H${ageDate.getHours()} M${ageDate.getMinutes()}`,
); );
result.push(element); result.push(element);
} }
OrchestratorLogger.log(``); CloudRunnerLogger.log(``);
return result; return result;
} }
public static async getTasks(): Promise<{ taskElement: Task; element: string }[]> { public static async getTasks() {
const result: { taskElement: Task; element: string }[] = []; // Extended Task type to include custom properties added in this method
OrchestratorLogger.log(``); type ExtendedTask = Task & {
OrchestratorLogger.log(`List Tasks`); overrides?: Record<string, unknown>;
attachments?: unknown[];
};
const result: { taskElement: ExtendedTask; element: string }[] = [];
CloudRunnerLogger.log(``);
CloudRunnerLogger.log(`List Tasks`);
process.env.AWS_REGION = Input.region; process.env.AWS_REGION = Input.region;
const ecs = AwsClientFactory.getECS(); const ecs = AwsClientFactory.getECS();
const clusters: string[] = []; const clusters = (await ecs.send(new ListClustersCommand({}))).clusterArns || [];
{ CloudRunnerLogger.log(`Task Clusters ${clusters.length}`);
let nextToken: string | undefined;
do {
const clusterResponse = await ecs.send(new ListClustersCommand({ nextToken }));
clusters.push(...(clusterResponse.clusterArns ?? []));
nextToken = clusterResponse.nextToken;
} while (nextToken);
}
OrchestratorLogger.log(`Task Clusters ${clusters.length}`);
for (const element of clusters) { for (const element of clusters) {
const taskArns: string[] = []; const input = {
{ cluster: element,
let nextToken: string | undefined; };
do { const list = (await ecs.send(new ListTasksCommand(input))).taskArns || [];
const taskResponse = await ecs.send(new ListTasksCommand({ cluster: element, nextToken })); if (list.length > 0) {
taskArns.push(...(taskResponse.taskArns ?? [])); const describeInput = { tasks: list, cluster: element };
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 describeList = (await ecs.send(new DescribeTasksCommand(describeInput))).tasks || [];
if (describeList.length === 0) { if (describeList.length === 0) {
OrchestratorLogger.log(`No Tasks`); CloudRunnerLogger.log(`No Tasks`);
continue; continue;
} }
OrchestratorLogger.log(`Tasks ${describeList.length}`); CloudRunnerLogger.log(`Tasks ${describeList.length}`);
for (const taskElement of describeList) { for (const taskElement of describeList) {
if (taskElement === undefined) { if (taskElement === undefined) {
continue; continue;
} }
if (taskElement.createdAt === undefined) { const extendedTask = taskElement as ExtendedTask;
OrchestratorLogger.log(`Skipping ${taskElement.taskDefinitionArn} no createdAt date`); extendedTask.overrides = {};
extendedTask.attachments = [];
if (extendedTask.createdAt === undefined) {
CloudRunnerLogger.log(`Skipping ${extendedTask.taskDefinitionArn} no createdAt date`);
continue; continue;
} }
result.push({ taskElement, element }); result.push({ taskElement: extendedTask, element });
} }
} }
} }
OrchestratorLogger.log(``); CloudRunnerLogger.log(``);
return result; return result;
} }
@@ -143,7 +141,7 @@ export class TaskService {
throw new Error('stack not defined'); throw new Error('stack not defined');
} }
if (!stack.CreationTime) { if (!stack.CreationTime) {
OrchestratorLogger.log(`${stack.StackName} due to undefined CreationTime`); CloudRunnerLogger.log(`${stack.StackName} due to undefined CreationTime`);
} }
const ageDate: Date = new Date(Date.now() - (stack.CreationTime?.getTime() ?? 0)); const ageDate: Date = new Date(Date.now() - (stack.CreationTime?.getTime() ?? 0));
const message = ` const message = `
@@ -153,43 +151,40 @@ export class TaskService {
${JSON.stringify(stackInfo, undefined, 4)} ${JSON.stringify(stackInfo, undefined, 4)}
${JSON.stringify(stackInfo2, undefined, 4)} ${JSON.stringify(stackInfo2, undefined, 4)}
`; `;
OrchestratorLogger.log(message); CloudRunnerLogger.log(message);
return message; return message;
} catch (error) { } catch (error) {
OrchestratorLogger.error( CloudRunnerLogger.error(
`Failed to describe job ${job}: ${error instanceof Error ? error.message : String(error)}`, `Failed to describe job ${job}: ${error instanceof Error ? error.message : String(error)}`,
); );
throw error; throw error;
} }
} }
public static async getLogGroups(): Promise<LogGroup[]> { public static async getLogGroups() {
const result: LogGroup[] = []; const result: LogGroup[] = [];
process.env.AWS_REGION = Input.region; process.env.AWS_REGION = Input.region;
const cwl = AwsClientFactory.getCloudWatchLogs(); const ecs = AwsClientFactory.getCloudWatchLogs();
let logStreamInput: DescribeLogGroupsCommandInput = { let logStreamInput: DescribeLogGroupsCommandInput = {
/* logGroupNamePrefix: 'game-ci' */ /* logGroupNamePrefix: 'game-ci' */
}; };
let logGroupsDescribe = await cwl.send(new DescribeLogGroupsCommand(logStreamInput)); let logGroupsDescribe = await ecs.send(new DescribeLogGroupsCommand(logStreamInput));
const logGroups = logGroupsDescribe.logGroups || []; const logGroups = logGroupsDescribe.logGroups || [];
while (logGroupsDescribe.nextToken) { while (logGroupsDescribe.nextToken) {
logStreamInput = { logStreamInput = { /* logGroupNamePrefix: 'game-ci',*/ nextToken: logGroupsDescribe.nextToken };
/* logGroupNamePrefix: 'game-ci',*/ logGroupsDescribe = await ecs.send(new DescribeLogGroupsCommand(logStreamInput));
nextToken: logGroupsDescribe.nextToken,
};
logGroupsDescribe = await cwl.send(new DescribeLogGroupsCommand(logStreamInput));
logGroups.push(...(logGroupsDescribe?.logGroups || [])); logGroups.push(...(logGroupsDescribe?.logGroups || []));
} }
OrchestratorLogger.log(`Log Groups ${logGroups.length}`); CloudRunnerLogger.log(`Log Groups ${logGroups.length}`);
for (const element of logGroups) { for (const element of logGroups) {
if (element.creationTime === undefined) { if (element.creationTime === undefined) {
OrchestratorLogger.log(`Skipping ${element.logGroupName} no createdAt date`); CloudRunnerLogger.log(`Skipping ${element.logGroupName} no createdAt date`);
continue; continue;
} }
const ageDate: Date = new Date(Date.now() - element.creationTime); const ageDate: Date = new Date(Date.now() - element.creationTime);
OrchestratorLogger.log( CloudRunnerLogger.log(
`Task Stack ${element.logGroupName} - Age D${Math.floor( `Task Stack ${element.logGroupName} - Age D${Math.floor(
ageDate.getHours() / 24, ageDate.getHours() / 24,
)} H${ageDate.getHours()} M${ageDate.getMinutes()}`, )} H${ageDate.getHours()} M${ageDate.getMinutes()}`,
@@ -199,22 +194,20 @@ export class TaskService {
return result; return result;
} }
public static async getLocks(): Promise<Array<{ Key: string }>> { public static async getLocks() {
process.env.AWS_REGION = Input.region; process.env.AWS_REGION = Input.region;
if (Orchestrator.buildParameters.storageProvider === 'rclone') { if (CloudRunner.buildParameters.storageProvider === 'rclone') {
// eslint-disable-next-line no-unused-vars const objects = await (SharedWorkspaceLocking as any).listObjects('');
type ListObjectsFunction = (prefix: string) => Promise<string[]>;
const objects = await (SharedWorkspaceLocking as unknown as { listObjects: ListObjectsFunction }).listObjects('');
return objects.map((x: string) => ({ Key: x })); return objects.map((x: string) => ({ Key: x }));
} }
const s3 = AwsClientFactory.getS3(); const s3 = AwsClientFactory.getS3();
const listRequest = { const listRequest = {
Bucket: Orchestrator.buildParameters.awsStackName, Bucket: CloudRunner.buildParameters.awsStackName,
}; };
const results = await s3.send(new ListObjectsV2Command(listRequest)); const results = await s3.send(new ListObjectsCommand(listRequest));
return (results.Contents || []).map((object) => ({ Key: object.Key || '' })); return results.Contents || [];
} }
} }
@@ -1,20 +1,20 @@
import BuildParameters from '../../../build-parameters'; import BuildParameters from '../../../build-parameters';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable'; import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger'; import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import { ProviderInterface } from '../provider-interface'; import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret'; import CloudRunnerSecret from '../../options/cloud-runner-secret';
import Docker from '../../../docker'; import Docker from '../../../docker';
import { Action } from '../../..'; import { Action } from '../../..';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import Orchestrator from '../../orchestrator'; import CloudRunner from '../../cloud-runner';
import { ProviderResource } from '../provider-resource'; import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow'; import { ProviderWorkflow } from '../provider-workflow';
import { OrchestratorSystem } from '../../services/core/orchestrator-system'; import { CloudRunnerSystem } from '../../services/core/cloud-runner-system';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { CommandHookService } from '../../services/hooks/command-hook-service'; import { CommandHookService } from '../../services/hooks/command-hook-service';
import { StringKeyValuePair } from '../../../shared-types'; import { StringKeyValuePair } from '../../../shared-types';
class LocalDockerOrchestrator implements ProviderInterface { class LocalDockerCloudRunner implements ProviderInterface {
public buildParameters!: BuildParameters; public buildParameters!: BuildParameters;
listResources(): Promise<ProviderResource[]> { listResources(): Promise<ProviderResource[]> {
@@ -50,15 +50,15 @@ class LocalDockerOrchestrator implements ProviderInterface {
const { workspace } = Action; const { workspace } = Action;
if ( if (
fs.existsSync( fs.existsSync(
`${workspace}/orchestrator-cache/cache/build/build-${buildParameters.buildGuid}.tar${ `${workspace}/cloud-runner-cache/cache/build/build-${buildParameters.buildGuid}.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : '' CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
}`, }`,
) )
) { ) {
await OrchestratorSystem.Run(`ls ${workspace}/orchestrator-cache/cache/build/`); await CloudRunnerSystem.Run(`ls ${workspace}/cloud-runner-cache/cache/build/`);
await OrchestratorSystem.Run( await CloudRunnerSystem.Run(
`rm -r ${workspace}/orchestrator-cache/cache/build/build-${buildParameters.buildGuid}.tar${ `rm -r ${workspace}/cloud-runner-cache/cache/build/build-${buildParameters.buildGuid}.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : '' CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
}`, }`,
); );
} }
@@ -80,47 +80,22 @@ class LocalDockerOrchestrator implements ProviderInterface {
commands: string, commands: string,
mountdir: string, mountdir: string,
workingdir: string, workingdir: string,
environment: OrchestratorEnvironmentVariable[], environment: CloudRunnerEnvironmentVariable[],
secrets: OrchestratorSecret[], secrets: CloudRunnerSecret[],
): Promise<string> { ): Promise<string> {
OrchestratorLogger.log(buildGuid); CloudRunnerLogger.log(buildGuid);
OrchestratorLogger.log(commands); CloudRunnerLogger.log(commands);
const { workspace, actionFolder } = Action; const { workspace, actionFolder } = Action;
const content: StringKeyValuePair[] = []; const content: StringKeyValuePair[] = [];
for (const x of secrets) { for (const x of secrets) {
content.push({ name: x.EnvironmentVariable, value: x.ParameterValue }); 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) { for (const x of environment) {
let value = x.value; content.push({ name: x.name, 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');
OrchestratorLogger.log(`Replaced localhost with host.docker.internal for ${x.name}: ${value}`);
}
content.push({ name: x.name, value });
} }
// if (this.buildParameters?.orchestratorIntegrationTests) { // if (this.buildParameters?.cloudRunnerIntegrationTests) {
// core.info(JSON.stringify(content, undefined, 4)); // core.info(JSON.stringify(content, undefined, 4));
// core.info(JSON.stringify(secrets, undefined, 4)); // core.info(JSON.stringify(secrets, undefined, 4));
// core.info(JSON.stringify(environment, undefined, 4)); // core.info(JSON.stringify(environment, undefined, 4));
@@ -137,33 +112,25 @@ class LocalDockerOrchestrator implements ProviderInterface {
// core.info(JSON.stringify({ workspace, actionFolder, ...this.buildParameters, ...content }, undefined, 4)); // core.info(JSON.stringify({ workspace, actionFolder, ...this.buildParameters, ...content }, undefined, 4));
const entrypointFilePath = `start.sh`; const entrypointFilePath = `start.sh`;
const fileContents = `#!/bin/bash
// Use #!/bin/sh for POSIX compatibility (Alpine-based images like rclone/rclone don't have bash)
const fileContents = `#!/bin/sh
set -e set -e
mkdir -p /github/workspace/orchestrator-cache mkdir -p /github/workspace/cloud-runner-cache
mkdir -p /data/cache mkdir -p /data/cache
cp -a /github/workspace/orchestrator-cache/. ${sharedFolder} cp -a /github/workspace/cloud-runner-cache/. ${sharedFolder}
${CommandHookService.ApplyHooksToCommands(commands, this.buildParameters)} ${CommandHookService.ApplyHooksToCommands(commands, this.buildParameters)}
# Only copy cache directory, exclude retained workspaces to avoid running out of disk space cp -a ${sharedFolder}. /github/workspace/cloud-runner-cache/
if [ -d "${sharedFolder}cache" ]; then
cp -a ${sharedFolder}cache/. /github/workspace/orchestrator-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/orchestrator-cache/ \\; || true
`; `;
writeFileSync(`${workspace}/${entrypointFilePath}`, fileContents, { writeFileSync(`${workspace}/${entrypointFilePath}`, fileContents, {
flag: 'w', flag: 'w',
}); });
if (Orchestrator.buildParameters.orchestratorDebug) { if (CloudRunner.buildParameters.cloudRunnerDebug) {
OrchestratorLogger.log(`Running local-docker: \n ${fileContents}`); CloudRunnerLogger.log(`Running local-docker: \n ${fileContents}`);
} }
if (fs.existsSync(`${workspace}/orchestrator-cache`)) { if (fs.existsSync(`${workspace}/cloud-runner-cache`)) {
await OrchestratorSystem.Run(`ls ${workspace}/orchestrator-cache && du -sh ${workspace}/orchestrator-cache`); await CloudRunnerSystem.Run(`ls ${workspace}/cloud-runner-cache && du -sh ${workspace}/cloud-runner-cache`);
} }
const exitCode = await Docker.run( const exitCode = await Docker.run(
image, image,
@@ -193,4 +160,4 @@ find ${sharedFolder} -maxdepth 1 -type f -name "test-*" -exec cp -a {} /github/w
return myOutput; return myOutput;
} }
} }
export default LocalDockerOrchestrator; export default LocalDockerCloudRunner;
@@ -2,22 +2,21 @@ import * as k8s from '@kubernetes/client-node';
import { BuildParameters } from '../../..'; import { BuildParameters } from '../../..';
import * as core from '@actions/core'; import * as core from '@actions/core';
import { ProviderInterface } from '../provider-interface'; import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret'; import CloudRunnerSecret from '../../options/cloud-runner-secret';
import KubernetesStorage from './kubernetes-storage'; import KubernetesStorage from './kubernetes-storage';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable'; import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
import KubernetesTaskRunner from './kubernetes-task-runner'; import KubernetesTaskRunner from './kubernetes-task-runner';
import KubernetesSecret from './kubernetes-secret'; import KubernetesSecret from './kubernetes-secret';
import KubernetesJobSpecFactory from './kubernetes-job-spec-factory'; import KubernetesJobSpecFactory from './kubernetes-job-spec-factory';
import KubernetesServiceAccount from './kubernetes-service-account'; import KubernetesServiceAccount from './kubernetes-service-account';
import OrchestratorLogger from '../../services/core/orchestrator-logger'; import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import { CoreV1Api } from '@kubernetes/client-node'; import { CoreV1Api } from '@kubernetes/client-node';
import Orchestrator from '../../orchestrator'; import CloudRunner from '../../cloud-runner';
import { ProviderResource } from '../provider-resource'; import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow'; import { ProviderWorkflow } from '../provider-workflow';
import { RemoteClientLogger } from '../../remote-client/remote-client-logger'; import { RemoteClientLogger } from '../../remote-client/remote-client-logger';
import { KubernetesRole } from './kubernetes-role'; import { KubernetesRole } from './kubernetes-role';
import { OrchestratorSystem } from '../../services/core/orchestrator-system'; import { CloudRunnerSystem } from '../../services/core/cloud-runner-system';
import ResourceTracking from '../../services/core/resource-tracking';
class Kubernetes implements ProviderInterface { class Kubernetes implements ProviderInterface {
public static Instance: Kubernetes; public static Instance: Kubernetes;
@@ -38,6 +37,7 @@ class Kubernetes implements ProviderInterface {
public serviceAccountName: string = ''; public serviceAccountName: string = '';
public ip: string = ''; public ip: string = '';
// eslint-disable-next-line no-unused-vars
constructor(buildParameters: BuildParameters) { constructor(buildParameters: BuildParameters) {
Kubernetes.Instance = this; Kubernetes.Instance = this;
this.kubeConfig = new k8s.KubeConfig(); this.kubeConfig = new k8s.KubeConfig();
@@ -46,8 +46,8 @@ class Kubernetes implements ProviderInterface {
this.kubeClientApps = this.kubeConfig.makeApiClient(k8s.AppsV1Api); this.kubeClientApps = this.kubeConfig.makeApiClient(k8s.AppsV1Api);
this.kubeClientBatch = this.kubeConfig.makeApiClient(k8s.BatchV1Api); this.kubeClientBatch = this.kubeConfig.makeApiClient(k8s.BatchV1Api);
this.rbacAuthorizationV1Api = this.kubeConfig.makeApiClient(k8s.RbacAuthorizationV1Api); this.rbacAuthorizationV1Api = this.kubeConfig.makeApiClient(k8s.RbacAuthorizationV1Api);
this.namespace = buildParameters.containerNamespace ? buildParameters.containerNamespace : 'default'; this.namespace = 'default';
OrchestratorLogger.log('Loaded default Kubernetes configuration for this environment'); CloudRunnerLogger.log('Loaded default Kubernetes configuration for this environment');
} }
async PushLogUpdate(logs: string) { async PushLogUpdate(logs: string) {
@@ -63,7 +63,7 @@ class Kubernetes implements ProviderInterface {
// logs to base64 // logs to base64
logs = Buffer.from(logs).toString('base64'); logs = Buffer.from(logs).toString('base64');
const response = await OrchestratorSystem.Run(`curl -X POST -d "${logs}" ${url}`, false, true); const response = await CloudRunnerSystem.Run(`curl -X POST -d "${logs}" ${url}`, false, true);
RemoteClientLogger.log(`Pushed logs to ${url} ${response}`); RemoteClientLogger.log(`Pushed logs to ${url} ${response}`);
} }
@@ -133,19 +133,16 @@ class Kubernetes implements ProviderInterface {
commands: string, commands: string,
mountdir: string, mountdir: string,
workingdir: string, workingdir: string,
environment: OrchestratorEnvironmentVariable[], environment: CloudRunnerEnvironmentVariable[],
secrets: OrchestratorSecret[], secrets: CloudRunnerSecret[],
): Promise<string> { ): Promise<string> {
try { try {
OrchestratorLogger.log('Orchestrator K8s workflow!'); CloudRunnerLogger.log('Cloud Runner K8s workflow!');
ResourceTracking.logAllocationSummary('k8s workflow');
await ResourceTracking.logDiskUsageSnapshot('k8s workflow (host)');
await ResourceTracking.logK3dNodeDiskUsage('k8s workflow (before job)');
// Setup // Setup
const id = const id =
BuildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(this.buildParameters) BuildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(this.buildParameters)
? Orchestrator.lockedWorkspace ? CloudRunner.lockedWorkspace
: this.buildParameters.buildGuid; : this.buildParameters.buildGuid;
this.pvcName = `unity-builder-pvc-${id}`; this.pvcName = `unity-builder-pvc-${id}`;
await KubernetesStorage.createPersistentVolumeClaim( await KubernetesStorage.createPersistentVolumeClaim(
@@ -159,134 +156,14 @@ class Kubernetes implements ProviderInterface {
this.jobName = `unity-builder-job-${this.buildGuid}`; this.jobName = `unity-builder-job-${this.buildGuid}`;
this.containerName = `main`; this.containerName = `main`;
await KubernetesSecret.createSecret(secrets, this.secretName, this.namespace, this.kubeClient); 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['orchestratorTests'] === 'true') {
try {
OrchestratorLogger.log('Cleaning up old images in k3d node before pulling new image...');
const { OrchestratorSystem: OrchestratorSystemModule } = await import(
'../../services/core/orchestrator-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 OrchestratorSystemModule.Run(cmd, true, true);
} catch (cmdError) {
// Ignore individual command failures - cleanup is best effort
OrchestratorLogger.log(`Cleanup command failed (non-fatal): ${cmdError}`);
}
}
OrchestratorLogger.log('Cleanup completed (containers and non-Unity images removed, Unity images preserved)');
} catch (cleanupError) {
OrchestratorLogger.logWarning(`Failed to cleanup images before job creation: ${cleanupError}`);
// Continue anyway - image might already be cached
}
}
let output = ''; let output = '';
try { try {
// Before creating the job, verify we have the Unity image cached on the agent node CloudRunnerLogger.log('Job does not exist');
// If not cached, try to ensure it's available to avoid disk pressure during pull
if (process.env['orchestratorTests'] === 'true' && image.includes('unityci/editor')) {
try {
const { OrchestratorSystem: OrchestratorSystemModule2 } = await import(
'../../services/core/orchestrator-system'
);
// Check if image is cached on agent node (where pods run)
const agentImageCheck = await OrchestratorSystemModule2.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 OrchestratorSystemModule2.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 OrchestratorSystemModule2.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,
);
OrchestratorLogger.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')) {
OrchestratorLogger.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) {
OrchestratorLogger.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 {
OrchestratorLogger.log('Unity image is cached on agent node - pod should start without pulling');
}
} catch (checkError) {
// Ignore check errors - continue with job creation
OrchestratorLogger.logWarning(`Failed to verify Unity image cache: ${checkError}`);
}
}
OrchestratorLogger.log('Job does not exist');
await this.createJob(commands, image, mountdir, workingdir, environment, secrets); await this.createJob(commands, image, mountdir, workingdir, environment, secrets);
OrchestratorLogger.log('Watching pod until running'); CloudRunnerLogger.log('Watching pod until running');
await KubernetesTaskRunner.watchUntilPodRunning(this.kubeClient, this.podName, this.namespace); await KubernetesTaskRunner.watchUntilPodRunning(this.kubeClient, this.podName, this.namespace);
OrchestratorLogger.log('Pod is running'); CloudRunnerLogger.log('Pod is running');
output += await KubernetesTaskRunner.runTask( output += await KubernetesTaskRunner.runTask(
this.kubeConfig, this.kubeConfig,
this.kubeClient, this.kubeClient,
@@ -296,9 +173,9 @@ class Kubernetes implements ProviderInterface {
this.namespace, this.namespace,
); );
} catch (error: any) { } catch (error: any) {
OrchestratorLogger.log(`error running k8s workflow ${error}`); CloudRunnerLogger.log(`error running k8s workflow ${error}`);
await new Promise((resolve) => setTimeout(resolve, 3000)); await new Promise((resolve) => setTimeout(resolve, 3000));
OrchestratorLogger.log( CloudRunnerLogger.log(
JSON.stringify( JSON.stringify(
(await this.kubeClient.listNamespacedEvent(this.namespace)).body.items (await this.kubeClient.listNamespacedEvent(this.namespace)).body.items
.map((x) => { .map((x) => {
@@ -321,7 +198,7 @@ class Kubernetes implements ProviderInterface {
return output; return output;
} catch (error) { } catch (error) {
OrchestratorLogger.log('Running job failed'); CloudRunnerLogger.log('Running job failed');
core.error(JSON.stringify(error, undefined, 4)); core.error(JSON.stringify(error, undefined, 4));
// await this.cleanupTaskResources(); // await this.cleanupTaskResources();
@@ -334,8 +211,8 @@ class Kubernetes implements ProviderInterface {
image: string, image: string,
mountdir: string, mountdir: string,
workingdir: string, workingdir: string,
environment: OrchestratorEnvironmentVariable[], environment: CloudRunnerEnvironmentVariable[],
secrets: OrchestratorSecret[], secrets: CloudRunnerSecret[],
) { ) {
await this.createNamespacedJob(commands, image, mountdir, workingdir, environment, secrets); await this.createNamespacedJob(commands, image, mountdir, workingdir, environment, secrets);
const find = await Kubernetes.findPodFromJob(this.kubeClient, this.jobName, this.namespace); const find = await Kubernetes.findPodFromJob(this.kubeClient, this.jobName, this.namespace);
@@ -359,8 +236,8 @@ class Kubernetes implements ProviderInterface {
image: string, image: string,
mountdir: string, mountdir: string,
workingdir: string, workingdir: string,
environment: OrchestratorEnvironmentVariable[], environment: CloudRunnerEnvironmentVariable[],
secrets: OrchestratorSecret[], secrets: CloudRunnerSecret[],
) { ) {
for (let index = 0; index < 3; index++) { for (let index = 0; index < 3; index++) {
try { try {
@@ -385,13 +262,13 @@ class Kubernetes implements ProviderInterface {
// await KubernetesRole.createRole(this.serviceAccountName, this.namespace, this.rbacAuthorizationV1Api); // await KubernetesRole.createRole(this.serviceAccountName, this.namespace, this.rbacAuthorizationV1Api);
const result = await this.kubeClientBatch.createNamespacedJob(this.namespace, jobSpec); const result = await this.kubeClientBatch.createNamespacedJob(this.namespace, jobSpec);
OrchestratorLogger.log(`Build job created`); CloudRunnerLogger.log(`Build job created`);
await new Promise((promise) => setTimeout(promise, 5000)); await new Promise((promise) => setTimeout(promise, 5000));
OrchestratorLogger.log('Job created'); CloudRunnerLogger.log('Job created');
return result.body.metadata?.name; return result.body.metadata?.name;
} catch (error) { } catch (error) {
OrchestratorLogger.log(`Error occured creating job: ${error}`); CloudRunnerLogger.log(`Error occured creating job: ${error}`);
throw error; throw error;
} }
} }
@@ -403,26 +280,26 @@ class Kubernetes implements ProviderInterface {
} }
async cleanupTaskResources() { async cleanupTaskResources() {
OrchestratorLogger.log('cleaning up'); CloudRunnerLogger.log('cleaning up');
try { try {
await this.kubeClientBatch.deleteNamespacedJob(this.jobName, this.namespace); await this.kubeClientBatch.deleteNamespacedJob(this.jobName, this.namespace);
await this.kubeClient.deleteNamespacedPod(this.podName, this.namespace); await this.kubeClient.deleteNamespacedPod(this.podName, this.namespace);
await KubernetesRole.deleteRole(this.serviceAccountName, this.namespace, this.rbacAuthorizationV1Api); await KubernetesRole.deleteRole(this.serviceAccountName, this.namespace, this.rbacAuthorizationV1Api);
} catch (error: any) { } catch (error: any) {
OrchestratorLogger.log(`Failed to cleanup`); CloudRunnerLogger.log(`Failed to cleanup`);
if (error.response.body.reason !== `NotFound`) { if (error.response.body.reason !== `NotFound`) {
OrchestratorLogger.log(`Wasn't a not found error: ${error.response.body.reason}`); CloudRunnerLogger.log(`Wasn't a not found error: ${error.response.body.reason}`);
throw error; throw error;
} }
} }
try { try {
await this.kubeClient.deleteNamespacedSecret(this.secretName, this.namespace); await this.kubeClient.deleteNamespacedSecret(this.secretName, this.namespace);
} catch (error: any) { } catch (error: any) {
OrchestratorLogger.log(`Failed to cleanup secret`); CloudRunnerLogger.log(`Failed to cleanup secret`);
OrchestratorLogger.log(error.response.body.reason); CloudRunnerLogger.log(error.response.body.reason);
} }
OrchestratorLogger.log('cleaned up Secret, Job and Pod'); CloudRunnerLogger.log('cleaned up Secret, Job and Pod');
OrchestratorLogger.log('cleaning up finished'); CloudRunnerLogger.log('cleaning up finished');
} }
async cleanupWorkflow( async cleanupWorkflow(
@@ -435,14 +312,14 @@ class Kubernetes implements ProviderInterface {
if (BuildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(buildParameters)) { if (BuildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(buildParameters)) {
return; return;
} }
OrchestratorLogger.log(`deleting PVC`); CloudRunnerLogger.log(`deleting PVC`);
try { try {
await this.kubeClient.deleteNamespacedPersistentVolumeClaim(this.pvcName, this.namespace); await this.kubeClient.deleteNamespacedPersistentVolumeClaim(this.pvcName, this.namespace);
await this.kubeClient.deleteNamespacedServiceAccount(this.serviceAccountName, this.namespace); await this.kubeClient.deleteNamespacedServiceAccount(this.serviceAccountName, this.namespace);
OrchestratorLogger.log('cleaned up PVC and Service Account'); CloudRunnerLogger.log('cleaned up PVC and Service Account');
} catch (error: any) { } catch (error: any) {
OrchestratorLogger.log(`Cleanup failed ${JSON.stringify(error, undefined, 4)}`); CloudRunnerLogger.log(`Cleanup failed ${JSON.stringify(error, undefined, 4)}`);
throw error; throw error;
} }
} }
@@ -0,0 +1,151 @@
import { V1EnvVar, V1EnvVarSource, V1SecretKeySelector } from '@kubernetes/client-node';
import BuildParameters from '../../../build-parameters';
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';
class KubernetesJobSpecFactory {
static getJobSpec(
command: string,
image: string,
mountdir: string,
workingDirectory: string,
environment: CloudRunnerEnvironmentVariable[],
secrets: CloudRunnerSecret[],
buildGuid: string,
buildParameters: BuildParameters,
secretName: string,
pvcName: string,
jobName: string,
k8s: any,
containerName: string,
ip: string = '',
) {
const endpointEnvNames = 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',
]);
const adjustedEnvironment = environment.map((x) => {
let value = x.value;
if (
typeof value === 'string' &&
endpointEnvNames.has(x.name) &&
(value.startsWith('http://localhost') || value.startsWith('http://127.0.0.1'))
) {
value = value
.replace('http://localhost', 'http://host.k3d.internal')
.replace('http://127.0.0.1', 'http://host.k3d.internal');
}
return { name: x.name, value } as CloudRunnerEnvironmentVariable;
});
const job = new k8s.V1Job();
job.apiVersion = 'batch/v1';
job.kind = 'Job';
job.metadata = {
name: jobName,
labels: {
app: 'unity-builder',
buildGuid,
},
};
job.spec = {
ttlSecondsAfterFinished: 9999,
backoffLimit: 0,
template: {
spec: {
volumes: [
{
name: 'build-mount',
persistentVolumeClaim: {
claimName: pvcName,
},
},
],
containers: [
{
ttlSecondsAfterFinished: 9999,
name: containerName,
image,
command: ['/bin/sh'],
args: [
'-c',
`${CommandHookService.ApplyHooksToCommands(`${command}\nsleep 2m`, CloudRunner.buildParameters)}`,
],
workingDir: `${workingDirectory}`,
resources: {
requests: {
memory: `${Number.parseInt(buildParameters.containerMemory) / 1024}G` || '750M',
cpu: Number.parseInt(buildParameters.containerCpu) / 1024 || '1',
},
},
env: [
...adjustedEnvironment.map((x) => {
const environmentVariable = new V1EnvVar();
environmentVariable.name = x.name;
environmentVariable.value = x.value;
return environmentVariable;
}),
...secrets.map((x) => {
const secret = new V1EnvVarSource();
secret.secretKeyRef = new V1SecretKeySelector();
secret.secretKeyRef.key = x.ParameterKey;
secret.secretKeyRef.name = secretName;
const environmentVariable = new V1EnvVar();
environmentVariable.name = x.EnvironmentVariable;
environmentVariable.valueFrom = secret;
return environmentVariable;
}),
{ name: 'LOG_SERVICE_IP', value: ip },
],
volumeMounts: [
{
name: 'build-mount',
mountPath: `${mountdir}`,
},
],
lifecycle: {
preStop: {
exec: {
command: [
`wait 60s;
cd /data/builder/action/steps;
chmod +x /return_license.sh;
/return_license.sh;`,
],
},
},
},
},
],
restartPolicy: 'Never',
},
},
};
if (process.env['CLOUD_RUNNER_MINIKUBE']) {
job.spec.template.spec.volumes[0] = {
name: 'build-mount',
hostPath: {
path: `/data`,
type: `Directory`,
},
};
}
job.spec.template.spec.containers[0].resources.requests[`ephemeral-storage`] = '10Gi';
return job;
}
}
export default KubernetesJobSpecFactory;
@@ -0,0 +1,23 @@
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import { CoreV1Api } from '@kubernetes/client-node';
class KubernetesPods {
public static async IsPodRunning(podName: string, namespace: string, kubeClient: CoreV1Api) {
const pods = (await kubeClient.listNamespacedPod(namespace)).body.items.filter((x) => podName === x.metadata?.name);
const running = pods.length > 0 && (pods[0].status?.phase === `Running` || pods[0].status?.phase === `Pending`);
const phase = pods[0]?.status?.phase || 'undefined status';
CloudRunnerLogger.log(`Getting pod status: ${phase}`);
if (phase === `Failed`) {
throw new Error(`K8s pod failed`);
}
return running;
}
public static async GetPodStatus(podName: string, namespace: string, kubeClient: CoreV1Api) {
const pods = (await kubeClient.listNamespacedPod(namespace)).body.items.find((x) => podName === x.metadata?.name);
const phase = pods?.status?.phase || 'undefined status';
return phase;
}
}
export default KubernetesPods;
@@ -1,12 +1,12 @@
import { CoreV1Api } from '@kubernetes/client-node'; import { CoreV1Api } from '@kubernetes/client-node';
import OrchestratorSecret from '../../options/orchestrator-secret'; import CloudRunnerSecret from '../../options/cloud-runner-secret';
import * as k8s from '@kubernetes/client-node'; import * as k8s from '@kubernetes/client-node';
import OrchestratorLogger from '../../services/core/orchestrator-logger'; import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import * as base64 from 'base-64'; import * as base64 from 'base-64';
class KubernetesSecret { class KubernetesSecret {
static async createSecret( static async createSecret(
secrets: OrchestratorSecret[], secrets: CloudRunnerSecret[],
secretName: string, secretName: string,
namespace: string, namespace: string,
kubeClient: CoreV1Api, kubeClient: CoreV1Api,
@@ -23,20 +23,20 @@ class KubernetesSecret {
for (const buildSecret of secrets) { for (const buildSecret of secrets) {
secret.data[buildSecret.ParameterKey] = base64.encode(buildSecret.ParameterValue); secret.data[buildSecret.ParameterKey] = base64.encode(buildSecret.ParameterValue);
} }
OrchestratorLogger.log(`Creating secret: ${secretName}`); CloudRunnerLogger.log(`Creating secret: ${secretName}`);
const existingSecrets = await kubeClient.listNamespacedSecret(namespace); const existingSecrets = await kubeClient.listNamespacedSecret(namespace);
const mappedSecrets = existingSecrets.body.items.map((x) => { const mappedSecrets = existingSecrets.body.items.map((x) => {
return x.metadata?.name || `no name`; return x.metadata?.name || `no name`;
}); });
OrchestratorLogger.log( CloudRunnerLogger.log(
`ExistsAlready: ${mappedSecrets.includes(secretName)} SecretsCount: ${mappedSecrets.length}`, `ExistsAlready: ${mappedSecrets.includes(secretName)} SecretsCount: ${mappedSecrets.length}`,
); );
await new Promise((promise) => setTimeout(promise, 15000)); await new Promise((promise) => setTimeout(promise, 15000));
await kubeClient.createNamespacedSecret(namespace, secret); await kubeClient.createNamespacedSecret(namespace, secret);
OrchestratorLogger.log('Created secret'); CloudRunnerLogger.log('Created secret');
} catch (error) { } catch (error) {
OrchestratorLogger.log(`Created secret failed ${error}`); CloudRunnerLogger.log(`Created secret failed ${error}`);
throw new Error(`Failed to create kubernetes secret`); throw new Error(`Failed to create kubernetes secret`);
} }
} }
@@ -0,0 +1,116 @@
import { waitUntil } from 'async-wait-until';
import * as core from '@actions/core';
import * as k8s from '@kubernetes/client-node';
import BuildParameters from '../../../build-parameters';
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import { IncomingMessage } from 'node:http';
import GitHub from '../../../github';
class KubernetesStorage {
public static async createPersistentVolumeClaim(
buildParameters: BuildParameters,
pvcName: string,
kubeClient: k8s.CoreV1Api,
namespace: string,
) {
if (buildParameters.kubeVolume !== ``) {
CloudRunnerLogger.log(`Kube Volume was input was set ${buildParameters.kubeVolume} overriding ${pvcName}`);
pvcName = buildParameters.kubeVolume;
return;
}
const allPvc = (await kubeClient.listNamespacedPersistentVolumeClaim(namespace)).body.items;
const pvcList = allPvc.map((x) => x.metadata?.name);
CloudRunnerLogger.log(`Current PVCs in namespace ${namespace}`);
CloudRunnerLogger.log(JSON.stringify(pvcList, undefined, 4));
if (pvcList.includes(pvcName)) {
CloudRunnerLogger.log(`pvc ${pvcName} already exists`);
if (GitHub.githubInputEnabled) {
core.setOutput('volume', pvcName);
}
return;
}
CloudRunnerLogger.log(`Creating PVC ${pvcName} (does not exist)`);
const result = await KubernetesStorage.createPVC(pvcName, buildParameters, kubeClient, namespace);
await KubernetesStorage.handleResult(result, kubeClient, namespace, pvcName);
}
public static async getPVCPhase(kubeClient: k8s.CoreV1Api, name: string, namespace: string) {
try {
return (await kubeClient.readNamespacedPersistentVolumeClaim(name, namespace)).body.status?.phase;
} catch (error) {
core.error('Failed to get PVC phase');
core.error(JSON.stringify(error, undefined, 4));
throw error;
}
}
public static async watchUntilPVCNotPending(kubeClient: k8s.CoreV1Api, name: string, namespace: string) {
try {
CloudRunnerLogger.log(`watch Until PVC Not Pending ${name} ${namespace}`);
CloudRunnerLogger.log(`${await this.getPVCPhase(kubeClient, name, namespace)}`);
await waitUntil(
async () => {
return (await this.getPVCPhase(kubeClient, name, namespace)) === 'Pending';
},
{
timeout: 750000,
intervalBetweenAttempts: 15000,
},
);
} catch (error: any) {
core.error('Failed to watch PVC');
core.error(error.toString());
core.error(
`PVC Body: ${JSON.stringify(
(await kubeClient.readNamespacedPersistentVolumeClaim(name, namespace)).body,
undefined,
4,
)}`,
);
throw error;
}
}
private static async createPVC(
pvcName: string,
buildParameters: BuildParameters,
kubeClient: k8s.CoreV1Api,
namespace: string,
) {
const pvc = new k8s.V1PersistentVolumeClaim();
pvc.apiVersion = 'v1';
pvc.kind = 'PersistentVolumeClaim';
pvc.metadata = {
name: pvcName,
};
pvc.spec = {
accessModes: ['ReadWriteOnce'],
storageClassName: buildParameters.kubeStorageClass === '' ? 'standard' : buildParameters.kubeStorageClass,
resources: {
requests: {
storage: buildParameters.kubeVolumeSize,
},
},
};
const result = await kubeClient.createNamespacedPersistentVolumeClaim(namespace, pvc);
return result;
}
private static async handleResult(
result: { response: IncomingMessage; body: k8s.V1PersistentVolumeClaim },
kubeClient: k8s.CoreV1Api,
namespace: string,
pvcName: string,
) {
const name = result.body.metadata?.name || '';
CloudRunnerLogger.log(`PVC ${name} created`);
await this.watchUntilPVCNotPending(kubeClient, name, namespace);
CloudRunnerLogger.log(`PVC ${name} is ready and not pending`);
core.setOutput('volume', pvcName);
}
}
export default KubernetesStorage;
@@ -0,0 +1,119 @@
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import { waitUntil } from 'async-wait-until';
import { CloudRunnerSystem } from '../../services/core/cloud-runner-system';
import CloudRunner from '../../cloud-runner';
import KubernetesPods from './kubernetes-pods';
import { FollowLogStreamService } from '../../services/core/follow-log-stream-service';
class KubernetesTaskRunner {
static readonly maxRetry: number = 3;
static lastReceivedMessage: string = ``;
static async runTask(
kubeConfig: KubeConfig,
kubeClient: CoreV1Api,
jobName: string,
podName: string,
containerName: string,
namespace: string,
) {
let output = '';
let shouldReadLogs = true;
let shouldCleanup = true;
let retriesAfterFinish = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
await new Promise((resolve) => setTimeout(resolve, 3000));
CloudRunnerLogger.log(
`Streaming logs from pod: ${podName} container: ${containerName} namespace: ${namespace} ${CloudRunner.buildParameters.kubeVolumeSize}/${CloudRunner.buildParameters.containerCpu}/${CloudRunner.buildParameters.containerMemory}`,
);
let extraFlags = ``;
extraFlags += (await KubernetesPods.IsPodRunning(podName, namespace, kubeClient))
? ` -f -c ${containerName}`
: ` --previous`;
const callback = (outputChunk: string) => {
output += outputChunk;
// split output chunk and handle per line
for (const chunk of outputChunk.split(`\n`)) {
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
chunk,
shouldReadLogs,
shouldCleanup,
output,
));
}
};
try {
await CloudRunnerSystem.Run(`kubectl logs ${podName}${extraFlags}`, false, true, callback);
} catch (error: any) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const continueStreaming = await KubernetesPods.IsPodRunning(podName, namespace, kubeClient);
CloudRunnerLogger.log(`K8s logging error ${error} ${continueStreaming}`);
if (continueStreaming) {
continue;
}
if (retriesAfterFinish < KubernetesTaskRunner.maxRetry) {
retriesAfterFinish++;
continue;
}
throw error;
}
if (FollowLogStreamService.DidReceiveEndOfTransmission) {
CloudRunnerLogger.log('end of log stream');
break;
}
}
return output;
}
static async watchUntilPodRunning(kubeClient: CoreV1Api, podName: string, namespace: string) {
let waitComplete: boolean = false;
let message = ``;
CloudRunnerLogger.log(`Watching ${podName} ${namespace}`);
await waitUntil(
async () => {
const status = await kubeClient.readNamespacedPodStatus(podName, namespace);
const phase = status?.body.status?.phase;
waitComplete = phase !== 'Pending';
message = `Phase:${status.body.status?.phase} \n Reason:${
status.body.status?.conditions?.[0].reason || ''
} \n Message:${status.body.status?.conditions?.[0].message || ''}`;
// 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 (waitComplete || phase !== 'Pending') return true;
return false;
},
{
timeout: 2000000,
intervalBetweenAttempts: 15000,
},
);
if (!waitComplete) {
CloudRunnerLogger.log(message);
}
return waitComplete;
}
}
export default KubernetesTaskRunner;
@@ -1,14 +1,13 @@
import BuildParameters from '../../../build-parameters'; import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system'; import { CloudRunnerSystem } from '../../services/core/cloud-runner-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable'; import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger'; import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import { ProviderInterface } from '../provider-interface'; import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret'; import CloudRunnerSecret from '../../options/cloud-runner-secret';
import { ProviderResource } from '../provider-resource'; import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow'; import { ProviderWorkflow } from '../provider-workflow';
import { quote } from 'shell-quote';
class LocalOrchestrator implements ProviderInterface { class LocalCloudRunner implements ProviderInterface {
listResources(): Promise<ProviderResource[]> { listResources(): Promise<ProviderResource[]> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
@@ -59,29 +58,52 @@ class LocalOrchestrator implements ProviderInterface {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
workingdir: string, workingdir: string,
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
environment: OrchestratorEnvironmentVariable[], environment: CloudRunnerEnvironmentVariable[],
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
secrets: OrchestratorSecret[], secrets: CloudRunnerSecret[],
): Promise<string> { ): Promise<string> {
OrchestratorLogger.log(image); CloudRunnerLogger.log(image);
OrchestratorLogger.log(buildGuid); CloudRunnerLogger.log(buildGuid);
OrchestratorLogger.log(commands); CloudRunnerLogger.log(commands);
// On Windows, many built-in hooks use POSIX shell syntax. Execute via bash if available. // On Windows, many built-in hooks use POSIX shell syntax. Execute via bash if available.
if (process.platform === 'win32') { if (process.platform === 'win32') {
const inline = commands // Properly escape the command string for embedding in a double-quoted bash string.
// Order matters: backslashes must be escaped first to avoid double-escaping.
const escapeForBashDoubleQuotes = (stringValue: string): string => {
return stringValue
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/\$/g, '\\$') // Escape dollar signs to prevent variable expansion
.replace(/`/g, '\\`') // Escape backticks to prevent command substitution
.replace(/"/g, '\\"'); // Escape double quotes
};
// Split commands by newlines and escape each line
const lines = commands
.replace(/\r/g, '') .replace(/\r/g, '')
.split('\n') .split('\n')
.filter((x) => x.trim().length > 0) .filter((x) => x.trim().length > 0)
.join(' ; '); .map((line) => escapeForBashDoubleQuotes(line));
// Use shell-quote to properly escape the command string, preventing command injection // Join with semicolons, but don't add semicolon after control flow keywords
const bashWrapped = `bash -lc ${quote([inline])}`; // Control flow keywords that shouldn't be followed by semicolons: then, else, do, fi, done, esac
const controlFlowKeywords = /\b(then|else|do|fi|done|esac)\s*$/;
const inline = lines
.map((line, index) => {
// Don't add semicolon if this line ends with a control flow keyword
if (controlFlowKeywords.test(line.trim()) || index === lines.length - 1) {
return line;
}
return await OrchestratorSystem.Run(bashWrapped); return `${line} ;`;
})
.join(' ');
const bashWrapped = `bash -lc "${inline}"`;
return await CloudRunnerSystem.Run(bashWrapped);
} }
return await OrchestratorSystem.Run(commands); return await CloudRunnerSystem.Run(commands);
} }
} }
export default LocalOrchestrator; export default LocalCloudRunner;
@@ -2,7 +2,7 @@ import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import * as fs from 'fs'; import * as fs from 'fs';
import path from 'path'; import path from 'path';
import OrchestratorLogger from '../services/core/orchestrator-logger'; import CloudRunnerLogger from '../services/core/cloud-runner-logger';
import { GitHubUrlInfo, generateCacheKey } from './provider-url-parser'; import { GitHubUrlInfo, generateCacheKey } from './provider-url-parser';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -32,7 +32,7 @@ export class ProviderGitManager {
private static ensureCacheDir(): void { private static ensureCacheDir(): void {
if (!fs.existsSync(this.CACHE_DIR)) { if (!fs.existsSync(this.CACHE_DIR)) {
fs.mkdirSync(this.CACHE_DIR, { recursive: true }); fs.mkdirSync(this.CACHE_DIR, { recursive: true });
OrchestratorLogger.log(`Created provider cache directory: ${this.CACHE_DIR}`); CloudRunnerLogger.log(`Created provider cache directory: ${this.CACHE_DIR}`);
} }
} }
@@ -69,15 +69,15 @@ export class ProviderGitManager {
// Remove existing directory if it exists // Remove existing directory if it exists
if (fs.existsSync(localPath)) { if (fs.existsSync(localPath)) {
OrchestratorLogger.log(`Removing existing directory: ${localPath}`); CloudRunnerLogger.log(`Removing existing directory: ${localPath}`);
fs.rmSync(localPath, { recursive: true, force: true }); fs.rmSync(localPath, { recursive: true, force: true });
} }
try { try {
OrchestratorLogger.log(`Cloning repository: ${urlInfo.url} to ${localPath}`); CloudRunnerLogger.log(`Cloning repository: ${urlInfo.url} to ${localPath}`);
const cloneCommand = `git clone --depth 1 --branch ${urlInfo.branch} ${urlInfo.url} "${localPath}"`; const cloneCommand = `git clone --depth 1 --branch ${urlInfo.branch} ${urlInfo.url} "${localPath}"`;
OrchestratorLogger.log(`Executing: ${cloneCommand}`); CloudRunnerLogger.log(`Executing: ${cloneCommand}`);
const { stderr } = await execAsync(cloneCommand, { const { stderr } = await execAsync(cloneCommand, {
timeout: this.GIT_TIMEOUT, timeout: this.GIT_TIMEOUT,
@@ -85,10 +85,10 @@ export class ProviderGitManager {
}); });
if (stderr && !stderr.includes('warning')) { if (stderr && !stderr.includes('warning')) {
OrchestratorLogger.log(`Git clone stderr: ${stderr}`); CloudRunnerLogger.log(`Git clone stderr: ${stderr}`);
} }
OrchestratorLogger.log(`Successfully cloned repository to: ${localPath}`); CloudRunnerLogger.log(`Successfully cloned repository to: ${localPath}`);
return { return {
success: true, success: true,
@@ -96,7 +96,7 @@ export class ProviderGitManager {
}; };
} catch (error: any) { } catch (error: any) {
const errorMessage = `Failed to clone repository ${urlInfo.url}: ${error.message}`; const errorMessage = `Failed to clone repository ${urlInfo.url}: ${error.message}`;
OrchestratorLogger.log(`Error: ${errorMessage}`); CloudRunnerLogger.log(`Error: ${errorMessage}`);
return { return {
success: false, success: false,
@@ -123,7 +123,7 @@ export class ProviderGitManager {
} }
try { try {
OrchestratorLogger.log(`Updating repository: ${localPath}`); CloudRunnerLogger.log(`Updating repository: ${localPath}`);
// Fetch latest changes // Fetch latest changes
await execAsync('git fetch origin', { await execAsync('git fetch origin', {
@@ -141,7 +141,7 @@ export class ProviderGitManager {
statusOutput.includes('Your branch is behind') || statusOutput.includes('can be fast-forwarded'); statusOutput.includes('Your branch is behind') || statusOutput.includes('can be fast-forwarded');
if (hasUpdates) { if (hasUpdates) {
OrchestratorLogger.log(`Updates available, pulling latest changes...`); CloudRunnerLogger.log(`Updates available, pulling latest changes...`);
// Reset to origin/branch to get latest changes // Reset to origin/branch to get latest changes
await execAsync(`git reset --hard origin/${urlInfo.branch}`, { await execAsync(`git reset --hard origin/${urlInfo.branch}`, {
@@ -149,14 +149,14 @@ export class ProviderGitManager {
cwd: localPath, cwd: localPath,
}); });
OrchestratorLogger.log(`Repository updated successfully`); CloudRunnerLogger.log(`Repository updated successfully`);
return { return {
success: true, success: true,
updated: true, updated: true,
}; };
} else { } else {
OrchestratorLogger.log(`Repository is already up to date`); CloudRunnerLogger.log(`Repository is already up to date`);
return { return {
success: true, success: true,
@@ -165,7 +165,7 @@ export class ProviderGitManager {
} }
} catch (error: any) { } catch (error: any) {
const errorMessage = `Failed to update repository ${localPath}: ${error.message}`; const errorMessage = `Failed to update repository ${localPath}: ${error.message}`;
OrchestratorLogger.log(`Error: ${errorMessage}`); CloudRunnerLogger.log(`Error: ${errorMessage}`);
return { return {
success: false, success: false,
@@ -184,11 +184,11 @@ export class ProviderGitManager {
this.ensureCacheDir(); this.ensureCacheDir();
if (this.isRepositoryCloned(urlInfo)) { if (this.isRepositoryCloned(urlInfo)) {
OrchestratorLogger.log(`Repository already exists locally, checking for updates...`); CloudRunnerLogger.log(`Repository already exists locally, checking for updates...`);
const updateResult = await this.updateRepository(urlInfo); const updateResult = await this.updateRepository(urlInfo);
if (!updateResult.success) { if (!updateResult.success) {
OrchestratorLogger.log(`Failed to update repository, attempting fresh clone...`); CloudRunnerLogger.log(`Failed to update repository, attempting fresh clone...`);
const cloneResult = await this.cloneRepository(urlInfo); const cloneResult = await this.cloneRepository(urlInfo);
if (!cloneResult.success) { if (!cloneResult.success) {
throw new Error(`Failed to ensure repository availability: ${cloneResult.error}`); throw new Error(`Failed to ensure repository availability: ${cloneResult.error}`);
@@ -199,7 +199,7 @@ export class ProviderGitManager {
return this.getLocalPath(urlInfo); return this.getLocalPath(urlInfo);
} else { } else {
OrchestratorLogger.log(`Repository not found locally, cloning...`); CloudRunnerLogger.log(`Repository not found locally, cloning...`);
const cloneResult = await this.cloneRepository(urlInfo); const cloneResult = await this.cloneRepository(urlInfo);
if (!cloneResult.success) { if (!cloneResult.success) {
@@ -236,14 +236,14 @@ export class ProviderGitManager {
for (const entryPoint of commonEntryPoints) { for (const entryPoint of commonEntryPoints) {
const fullPath = path.join(localPath, entryPoint); const fullPath = path.join(localPath, entryPoint);
if (fs.existsSync(fullPath)) { if (fs.existsSync(fullPath)) {
OrchestratorLogger.log(`Found provider entry point: ${entryPoint}`); CloudRunnerLogger.log(`Found provider entry point: ${entryPoint}`);
return fullPath; return fullPath;
} }
} }
// Default to repository root // Default to repository root
OrchestratorLogger.log(`No specific entry point found, using repository root`); CloudRunnerLogger.log(`No specific entry point found, using repository root`);
return localPath; return localPath;
} }
@@ -266,13 +266,13 @@ export class ProviderGitManager {
const stats = fs.statSync(entryPath); const stats = fs.statSync(entryPath);
if (now - stats.mtime.getTime() > maxAge) { if (now - stats.mtime.getTime() > maxAge) {
OrchestratorLogger.log(`Cleaning up old repository: ${entry.name}`); CloudRunnerLogger.log(`Cleaning up old repository: ${entry.name}`);
fs.rmSync(entryPath, { recursive: true, force: true }); fs.rmSync(entryPath, { recursive: true, force: true });
} }
} }
} }
} catch (error: any) { } catch (error: any) {
OrchestratorLogger.log(`Error during cleanup: ${error.message}`); CloudRunnerLogger.log(`Error during cleanup: ${error.message}`);
} }
} }
} }
@@ -1,6 +1,6 @@
import BuildParameters from '../../build-parameters'; import BuildParameters from '../../build-parameters';
import OrchestratorEnvironmentVariable from '../options/orchestrator-environment-variable'; import CloudRunnerEnvironmentVariable from '../options/cloud-runner-environment-variable';
import OrchestratorSecret from '../options/orchestrator-secret'; import CloudRunnerSecret from '../options/cloud-runner-secret';
import { ProviderResource } from './provider-resource'; import { ProviderResource } from './provider-resource';
import { ProviderWorkflow } from './provider-workflow'; import { ProviderWorkflow } from './provider-workflow';
@@ -35,9 +35,9 @@ export interface ProviderInterface {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
workingdir: string, workingdir: string,
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
environment: OrchestratorEnvironmentVariable[], environment: CloudRunnerEnvironmentVariable[],
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
secrets: OrchestratorSecret[], secrets: CloudRunnerSecret[],
): Promise<string>; ): Promise<string>;
garbageCollect( garbageCollect(
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
@@ -1,6 +1,6 @@
import { ProviderInterface } from './provider-interface'; import { ProviderInterface } from './provider-interface';
import BuildParameters from '../../build-parameters'; import BuildParameters from '../../build-parameters';
import OrchestratorLogger from '../services/core/orchestrator-logger'; import CloudRunnerLogger from '../services/core/cloud-runner-logger';
import { parseProviderSource, logProviderSource, ProviderSourceInfo } from './provider-url-parser'; import { parseProviderSource, logProviderSource, ProviderSourceInfo } from './provider-url-parser';
import { ProviderGitManager } from './provider-git-manager'; import { ProviderGitManager } from './provider-git-manager';
@@ -16,7 +16,7 @@ export default async function loadProvider(
providerSource: string, providerSource: string,
buildParameters: BuildParameters, buildParameters: BuildParameters,
): Promise<ProviderInterface> { ): Promise<ProviderInterface> {
OrchestratorLogger.log(`Loading provider: ${providerSource}`); CloudRunnerLogger.log(`Loading provider: ${providerSource}`);
// Parse the provider source to determine its type // Parse the provider source to determine its type
const sourceInfo = parseProviderSource(providerSource); const sourceInfo = parseProviderSource(providerSource);
@@ -29,7 +29,7 @@ export default async function loadProvider(
// Handle different source types // Handle different source types
switch (sourceInfo.type) { switch (sourceInfo.type) {
case 'github': { case 'github': {
OrchestratorLogger.log(`Processing GitHub repository: ${sourceInfo.owner}/${sourceInfo.repo}`); CloudRunnerLogger.log(`Processing GitHub repository: ${sourceInfo.owner}/${sourceInfo.repo}`);
// Ensure the repository is available locally // Ensure the repository is available locally
const localRepoPath = await ProviderGitManager.ensureRepositoryAvailable(sourceInfo); const localRepoPath = await ProviderGitManager.ensureRepositoryAvailable(sourceInfo);
@@ -37,19 +37,19 @@ export default async function loadProvider(
// Get the path to the provider module within the repository // Get the path to the provider module within the repository
modulePath = ProviderGitManager.getProviderModulePath(sourceInfo, localRepoPath); modulePath = ProviderGitManager.getProviderModulePath(sourceInfo, localRepoPath);
OrchestratorLogger.log(`Loading provider from: ${modulePath}`); CloudRunnerLogger.log(`Loading provider from: ${modulePath}`);
break; break;
} }
case 'local': { case 'local': {
modulePath = sourceInfo.path; modulePath = sourceInfo.path;
OrchestratorLogger.log(`Loading provider from local path: ${modulePath}`); CloudRunnerLogger.log(`Loading provider from local path: ${modulePath}`);
break; break;
} }
case 'npm': { case 'npm': {
modulePath = sourceInfo.packageName; modulePath = sourceInfo.packageName;
OrchestratorLogger.log(`Loading provider from NPM package: ${modulePath}`); CloudRunnerLogger.log(`Loading provider from NPM package: ${modulePath}`);
break; break;
} }
@@ -58,17 +58,14 @@ export default async function loadProvider(
const providerModuleMap: Record<string, string> = { const providerModuleMap: Record<string, string> = {
aws: './aws', aws: './aws',
k8s: './k8s', k8s: './k8s',
cli: './cli',
test: './test', test: './test',
'local-docker': './docker', 'local-docker': './docker',
'local-system': './local', 'local-system': './local',
local: './local', local: './local',
'gcp-cloud-run': './gcp-cloud-run',
'azure-aci': './azure-aci',
}; };
modulePath = providerModuleMap[providerSource] || providerSource; modulePath = providerModuleMap[providerSource] || providerSource;
OrchestratorLogger.log(`Loading provider from module path: ${modulePath}`); CloudRunnerLogger.log(`Loading provider from module path: ${modulePath}`);
break; break;
} }
} }
@@ -114,7 +111,7 @@ export default async function loadProvider(
} }
} }
OrchestratorLogger.log(`Successfully loaded provider: ${providerSource}`); CloudRunnerLogger.log(`Successfully loaded provider: ${providerSource}`);
return instance as ProviderInterface; return instance as ProviderInterface;
} }
@@ -139,7 +136,7 @@ export class ProviderLoader {
* @returns string[] - Array of available provider names * @returns string[] - Array of available provider names
*/ */
static getAvailableProviders(): string[] { static getAvailableProviders(): string[] {
return ['aws', 'k8s', 'cli', 'test', 'local-docker', 'local-system', 'local', 'gcp-cloud-run', 'azure-aci']; return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local'];
} }
/** /**
@@ -1,4 +1,4 @@
import OrchestratorLogger from '../services/core/orchestrator-logger'; import CloudRunnerLogger from '../services/core/cloud-runner-logger';
export interface GitHubUrlInfo { export interface GitHubUrlInfo {
type: 'github'; type: 'github';
@@ -115,24 +115,24 @@ export function isGitHubSource(source: string): boolean {
* @param parsed The parsed source information * @param parsed The parsed source information
*/ */
export function logProviderSource(source: string, parsed: ProviderSourceInfo): void { export function logProviderSource(source: string, parsed: ProviderSourceInfo): void {
OrchestratorLogger.log(`Provider source: ${source}`); CloudRunnerLogger.log(`Provider source: ${source}`);
switch (parsed.type) { switch (parsed.type) {
case 'github': case 'github':
OrchestratorLogger.log(` Type: GitHub repository`); CloudRunnerLogger.log(` Type: GitHub repository`);
OrchestratorLogger.log(` Owner: ${parsed.owner}`); CloudRunnerLogger.log(` Owner: ${parsed.owner}`);
OrchestratorLogger.log(` Repository: ${parsed.repo}`); CloudRunnerLogger.log(` Repository: ${parsed.repo}`);
OrchestratorLogger.log(` Branch: ${parsed.branch}`); CloudRunnerLogger.log(` Branch: ${parsed.branch}`);
if (parsed.path) { if (parsed.path) {
OrchestratorLogger.log(` Path: ${parsed.path}`); CloudRunnerLogger.log(` Path: ${parsed.path}`);
} }
break; break;
case 'local': case 'local':
OrchestratorLogger.log(` Type: Local path`); CloudRunnerLogger.log(` Type: Local path`);
OrchestratorLogger.log(` Path: ${parsed.path}`); CloudRunnerLogger.log(` Path: ${parsed.path}`);
break; break;
case 'npm': case 'npm':
OrchestratorLogger.log(` Type: NPM package`); CloudRunnerLogger.log(` Type: NPM package`);
OrchestratorLogger.log(` Package: ${parsed.packageName}`); CloudRunnerLogger.log(` Package: ${parsed.packageName}`);
break; break;
} }
} }
@@ -1,12 +1,12 @@
import BuildParameters from '../../../build-parameters'; import BuildParameters from '../../../build-parameters';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable'; import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger'; import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
import { ProviderInterface } from '../provider-interface'; import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret'; import CloudRunnerSecret from '../../options/cloud-runner-secret';
import { ProviderResource } from '../provider-resource'; import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow'; import { ProviderWorkflow } from '../provider-workflow';
class TestOrchestrator implements ProviderInterface { class TestCloudRunner implements ProviderInterface {
listResources(): Promise<ProviderResource[]> { listResources(): Promise<ProviderResource[]> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
@@ -51,17 +51,17 @@ class TestOrchestrator implements ProviderInterface {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
workingdir: string, workingdir: string,
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
environment: OrchestratorEnvironmentVariable[], environment: CloudRunnerEnvironmentVariable[],
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
secrets: OrchestratorSecret[], secrets: CloudRunnerSecret[],
): Promise<string> { ): Promise<string> {
OrchestratorLogger.log(image); CloudRunnerLogger.log(image);
OrchestratorLogger.log(buildGuid); CloudRunnerLogger.log(buildGuid);
OrchestratorLogger.log(commands); CloudRunnerLogger.log(commands);
return await new Promise((result) => { return await new Promise((result) => {
result(commands); result(commands);
}); });
} }
} }
export default TestOrchestrator; export default TestCloudRunner;
@@ -0,0 +1,181 @@
import { assert } from 'node:console';
import fs from 'node:fs';
import path from 'node:path';
import CloudRunner from '../cloud-runner';
import CloudRunnerLogger from '../services/core/cloud-runner-logger';
import { CloudRunnerFolders } from '../options/cloud-runner-folders';
import { CloudRunnerSystem } from '../services/core/cloud-runner-system';
import { LfsHashing } from '../services/utility/lfs-hashing';
import { RemoteClientLogger } from './remote-client-logger';
import { Cli } from '../../cli/cli';
import { CliFunction } from '../../cli/cli-functions-repository';
// eslint-disable-next-line github/no-then
const fileExists = async (fpath: fs.PathLike) => !!(await fs.promises.stat(fpath).catch(() => false));
export class Caching {
@CliFunction(`cache-push`, `push to cache`)
static async cachePush() {
try {
const buildParameter = JSON.parse(process.env.BUILD_PARAMETERS || '{}');
CloudRunner.buildParameters = buildParameter;
await Caching.PushToCache(
Cli.options!['cachePushTo'],
Cli.options!['cachePushFrom'],
Cli.options!['artifactName'] || '',
);
} catch (error: any) {
CloudRunnerLogger.log(`${error}`);
}
}
@CliFunction(`cache-pull`, `pull from cache`)
static async cachePull() {
try {
const buildParameter = JSON.parse(process.env.BUILD_PARAMETERS || '{}');
CloudRunner.buildParameters = buildParameter;
await Caching.PullFromCache(
Cli.options!['cachePushFrom'],
Cli.options!['cachePushTo'],
Cli.options!['artifactName'] || '',
);
} catch (error: any) {
CloudRunnerLogger.log(`${error}`);
}
}
public static async PushToCache(cacheFolder: string, sourceFolder: string, cacheArtifactName: string) {
CloudRunnerLogger.log(`Pushing to cache ${sourceFolder}`);
cacheArtifactName = cacheArtifactName.replace(' ', '');
const startPath = process.cwd();
let compressionSuffix = '';
if (CloudRunner.buildParameters.useCompressionStrategy === true) {
compressionSuffix = `.lz4`;
}
CloudRunnerLogger.log(`Compression: ${CloudRunner.buildParameters.useCompressionStrategy} ${compressionSuffix}`);
try {
if (!(await fileExists(cacheFolder))) {
await CloudRunnerSystem.Run(`mkdir -p ${cacheFolder}`);
}
process.chdir(path.resolve(sourceFolder, '..'));
if (CloudRunner.buildParameters.cloudRunnerDebug === true) {
CloudRunnerLogger.log(
`Hashed cache folder ${await LfsHashing.hashAllFiles(sourceFolder)} ${sourceFolder} ${path.basename(
sourceFolder,
)}`,
);
}
const contents = await fs.promises.readdir(path.basename(sourceFolder));
CloudRunnerLogger.log(
`There is ${contents.length} files/dir in the source folder ${path.basename(sourceFolder)}`,
);
if (contents.length === 0) {
CloudRunnerLogger.log(
`Did not push source folder to cache because it was empty ${path.basename(sourceFolder)}`,
);
process.chdir(`${startPath}`);
return;
}
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');
await CloudRunnerSystem.Run(`mv ${cacheArtifactName}.tar${compressionSuffix} ${cacheFolder}`);
RemoteClientLogger.log(`moved cache entry ${cacheArtifactName} to ${cacheFolder}`);
assert(
await fileExists(`${path.join(cacheFolder, cacheArtifactName)}.tar${compressionSuffix}`),
'cache archive exists inside cache folder',
);
} catch (error) {
process.chdir(`${startPath}`);
throw error;
}
process.chdir(`${startPath}`);
}
public static async PullFromCache(cacheFolder: string, destinationFolder: string, cacheArtifactName: string = ``) {
CloudRunnerLogger.log(`Pulling from cache ${destinationFolder} ${CloudRunner.buildParameters.skipCache}`);
if (`${CloudRunner.buildParameters.skipCache}` === `true`) {
CloudRunnerLogger.log(`Skipping cache debugSkipCache is true`);
return;
}
cacheArtifactName = cacheArtifactName.replace(' ', '');
let compressionSuffix = '';
if (CloudRunner.buildParameters.useCompressionStrategy === true) {
compressionSuffix = `.lz4`;
}
const startPath = process.cwd();
RemoteClientLogger.log(`Caching for (lz4 ${compressionSuffix}) ${path.basename(destinationFolder)}`);
try {
if (!(await fileExists(cacheFolder))) {
await fs.promises.mkdir(cacheFolder);
}
if (!(await fileExists(destinationFolder))) {
await fs.promises.mkdir(destinationFolder);
}
const latestInBranch = await (
await CloudRunnerSystem.Run(`ls -t "${cacheFolder}" | grep .tar${compressionSuffix}$ | head -1`)
)
.replace(/\n/g, ``)
.replace(`.tar${compressionSuffix}`, '');
process.chdir(cacheFolder);
const cacheSelection =
cacheArtifactName !== `` && (await fileExists(`${cacheArtifactName}.tar${compressionSuffix}`))
? cacheArtifactName
: latestInBranch;
await CloudRunnerLogger.log(`cache key ${cacheArtifactName} selection ${cacheSelection}`);
if (await fileExists(`${cacheSelection}.tar${compressionSuffix}`)) {
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);
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, '..');
if (await fileExists(destinationFolder)) {
await fs.promises.rmdir(destinationFolder, { recursive: true });
}
await CloudRunnerSystem.Run(
`mv "${path.join(fullResultsFolder, path.basename(destinationFolder))}" "${destinationParentFolder}"`,
);
const contents = await fs.promises.readdir(
path.join(destinationParentFolder, path.basename(destinationFolder)),
);
CloudRunnerLogger.log(
`There is ${contents.length} files/dir in the cache pulled contents for ${path.basename(destinationFolder)}`,
);
} else {
RemoteClientLogger.logWarning(`cache item ${cacheArtifactName} doesn't exist ${destinationFolder}`);
if (cacheSelection !== ``) {
RemoteClientLogger.logWarning(
`cache item ${cacheArtifactName}.tar${compressionSuffix} doesn't exist ${destinationFolder}`,
);
throw new Error(`Failed to get cache item, but cache hit was found: ${cacheSelection}`);
}
}
} catch (error) {
process.chdir(startPath);
throw error;
}
process.chdir(startPath);
}
public static async handleCachePurging() {
if (process.env.PURGE_REMOTE_BUILDER_CACHE !== undefined) {
RemoteClientLogger.log(`purging ${CloudRunnerFolders.purgeRemoteCaching}`);
fs.promises.rmdir(CloudRunnerFolders.cacheFolder, { recursive: true });
}
}
}
@@ -0,0 +1,404 @@
import fs from 'node:fs';
import CloudRunner from '../cloud-runner';
import { CloudRunnerFolders } from '../options/cloud-runner-folders';
import { Caching } from './caching';
import { LfsHashing } from '../services/utility/lfs-hashing';
import { RemoteClientLogger } from './remote-client-logger';
import path from 'node:path';
import { assert } from 'node:console';
import CloudRunnerLogger from '../services/core/cloud-runner-logger';
import { CliFunction } from '../../cli/cli-functions-repository';
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';
export class RemoteClient {
@CliFunction(`remote-cli-pre-build`, `sets up a repository, usually before a game-ci build`)
static async setupRemoteClient() {
CloudRunnerLogger.log(`bootstrap game ci cloud runner...`);
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');
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) {
if (CloudRunnerOptions.providerStrategy !== 'k8s') {
CloudRunnerLogger.log(element);
} else {
fs.appendFileSync(logFile, element);
CloudRunnerLogger.log(element);
}
}
});
process.stdin.on('end', () => {
if (CloudRunnerOptions.providerStrategy !== 'k8s') {
CloudRunnerLogger.log(lingeringLine);
} else {
fs.appendFileSync(logFile, lingeringLine);
CloudRunnerLogger.log(lingeringLine);
}
});
}
@CliFunction(`remote-cli-post-build`, `runs a cloud runner build`)
public static async remoteClientPostBuild(): Promise<string> {
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)) {
const libraryEntries = await fs.promises.readdir(libraryFolderHost).catch(() => [] as string[]);
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)) {
const buildEntries = await fs.promises.readdir(buildFolderHost).catch(() => [] as string[]);
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();
// Ensure success marker is present in logs for tests
CloudRunnerLogger.log(`Activation successful`);
return new Promise((result) => result(``));
}
static async runCustomHookFiles(hookLifecycle: string) {
RemoteClientLogger.log(`RunCustomHookFiles: ${hookLifecycle}`);
const gameCiCustomHooksPath = path.join(CloudRunnerFolders.repoPathAbsolute, `game-ci`, `hooks`);
try {
const files = fs.readdirSync(gameCiCustomHooksPath);
for (const file of files) {
const fileContents = fs.readFileSync(path.join(gameCiCustomHooksPath, file), `utf8`);
const fileContentsObject = YAML.parse(fileContents.toString());
if (fileContentsObject.hook === hookLifecycle) {
RemoteClientLogger.log(`Active Hook File ${file} \n \n file contents: \n ${fileContents}`);
await CloudRunnerSystem.Run(fileContentsObject.commands);
}
}
} catch (error) {
RemoteClientLogger.log(JSON.stringify(error, undefined, 4));
}
}
public static async bootstrapRepository() {
await CloudRunnerSystem.Run(
`mkdir -p ${CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute)}`,
);
await CloudRunnerSystem.Run(
`mkdir -p ${CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.cacheFolderForCacheKeyFull)}`,
);
await RemoteClient.cloneRepoWithoutLFSFiles();
await RemoteClient.sizeOfFolder(
'repo before lfs cache pull',
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.repoPathAbsolute),
);
const lfsHashes = await LfsHashing.createLFSHashFiles();
if (fs.existsSync(CloudRunnerFolders.libraryFolderAbsolute)) {
RemoteClientLogger.logWarning(`!Warning!: The Unity library was included in the git repository`);
}
await Caching.PullFromCache(
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.lfsCacheFolderFull),
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.lfsFolderAbsolute),
`${lfsHashes.lfsGuidSum}`,
);
await RemoteClient.sizeOfFolder('repo after lfs cache pull', CloudRunnerFolders.repoPathAbsolute);
await RemoteClient.pullLatestLFS();
await RemoteClient.sizeOfFolder('repo before lfs git pull', CloudRunnerFolders.repoPathAbsolute);
await Caching.PushToCache(
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.lfsCacheFolderFull),
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.lfsFolderAbsolute),
`${lfsHashes.lfsGuidSum}`,
);
await Caching.PullFromCache(
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.libraryCacheFolderFull),
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.libraryFolderAbsolute),
);
await RemoteClient.sizeOfFolder('repo after library cache pull', CloudRunnerFolders.repoPathAbsolute);
await Caching.handleCachePurging();
}
private static async sizeOfFolder(message: string, folder: string) {
if (CloudRunner.buildParameters.cloudRunnerDebug) {
CloudRunnerLogger.log(`Size of ${message}`);
await CloudRunnerSystem.Run(`du -sh ${folder}`);
}
}
private static async cloneRepoWithoutLFSFiles() {
process.chdir(`${CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute}`);
if (
fs.existsSync(CloudRunnerFolders.repoPathAbsolute) &&
!fs.existsSync(path.join(CloudRunnerFolders.repoPathAbsolute, `.git`))
) {
await CloudRunnerSystem.Run(`rm -r ${CloudRunnerFolders.repoPathAbsolute}`);
CloudRunnerLogger.log(`${CloudRunnerFolders.repoPathAbsolute} repo exists, but no git folder, cleaning up`);
}
if (
BuildParameters.shouldUseRetainedWorkspaceMode(CloudRunner.buildParameters) &&
fs.existsSync(path.join(CloudRunnerFolders.repoPathAbsolute, `.git`))
) {
process.chdir(CloudRunnerFolders.repoPathAbsolute);
RemoteClientLogger.log(
`${
CloudRunnerFolders.repoPathAbsolute
} repo exists - skipping clone - retained workspace mode ${BuildParameters.shouldUseRetainedWorkspaceMode(
CloudRunner.buildParameters,
)}`,
);
await CloudRunnerSystem.Run(`git fetch && git reset --hard ${CloudRunner.buildParameters.gitSha}`);
return;
}
RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
await CloudRunnerSystem.Run(`git config --global advice.detachedHead false`);
RemoteClientLogger.log(`Cloning the repository being built:`);
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 {
await CloudRunnerSystem.Run(
`git clone ${CloudRunnerFolders.targetBuildRepoUrl} ${path.basename(CloudRunnerFolders.repoPathAbsolute)}`,
);
} catch (error: any) {
throw error;
}
process.chdir(CloudRunnerFolders.repoPathAbsolute);
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 (_error) {
try {
await CloudRunnerSystem.Run(`git fetch origin ${targetSha} || true`);
await CloudRunnerSystem.Run(`git checkout ${targetSha}`);
} catch (_error2) {
RemoteClientLogger.logWarning(`Falling back to branch checkout; SHA not found: ${targetSha}`);
try {
await CloudRunnerSystem.Run(`git checkout ${targetBranch}`);
} catch (_error3) {
if ((targetBranch || '').startsWith('pull/')) {
await CloudRunnerSystem.Run(`git checkout origin/${targetBranch}`);
} else {
throw _error2;
}
}
}
}
} else {
try {
await CloudRunnerSystem.Run(`git checkout ${targetBranch}`);
} catch (_error) {
if ((targetBranch || '').startsWith('pull/')) {
await CloudRunnerSystem.Run(`git checkout origin/${targetBranch}`);
} else {
throw _error;
}
}
RemoteClientLogger.log(`buildParameter Git Sha is empty`);
}
assert(fs.existsSync(path.join(`.git`, `lfs`)), 'LFS folder should not exist before caching');
RemoteClientLogger.log(`Checked out ${CloudRunner.buildParameters.branch}`);
}
static async replaceLargePackageReferencesWithSharedReferences() {
CloudRunnerLogger.log(`Use Shared Pkgs ${CloudRunner.buildParameters.useLargePackages}`);
GitHub.updateGitHubCheck(`Use Shared Pkgs ${CloudRunner.buildParameters.useLargePackages}`, ``);
if (CloudRunner.buildParameters.useLargePackages) {
const filePath = path.join(CloudRunnerFolders.projectPathAbsolute, `Packages/manifest.json`);
let manifest = fs.readFileSync(filePath, 'utf8');
manifest = manifest.replace(/LargeContent/g, '../../../LargeContent');
fs.writeFileSync(filePath, manifest);
CloudRunnerLogger.log(`Package Manifest \n ${manifest}`);
GitHub.updateGitHubCheck(`Package Manifest \n ${manifest}`, ``);
}
}
private static async pullLatestLFS() {
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;
}
// 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 (_error) {
/* 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)) &&
fs.existsSync(CloudRunnerFolders.ToLinuxFolder(path.join(CloudRunnerFolders.repoPathAbsolute, `.git`)))
) {
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 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 (_error) {
RemoteClientLogger.logWarning(`Retained workspace: SHA not found, falling back to branch ${branch}`);
try {
await CloudRunnerSystem.Run(`git checkout ${branch}`);
} catch (_error2) {
if ((branch || '').startsWith('pull/')) {
await CloudRunnerSystem.Run(`git checkout origin/${branch}`);
} else {
throw _error2;
}
}
}
return true;
}
return false;
}
}
@@ -0,0 +1,93 @@
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);
}
public static logCliError(message: string) {
CloudRunnerLogger.log(`[Client][Error] ${message}`);
}
public static logCliDiagnostic(message: string) {
CloudRunnerLogger.log(`[Client][Diagnostic] ${message}`);
}
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;
}
CloudRunnerLogger.log(`Collected Logs`);
// check for log file not existing
if (!fs.existsSync(RemoteClientLogger.LogFilePath)) {
CloudRunnerLogger.log(`Log file does not exist`);
// check if CloudRunner.isCloudRunnerEnvironment is true, log
if (!CloudRunner.isCloudRunnerEnvironment) {
CloudRunnerLogger.log(`Cloud Runner is not running in a cloud environment, not collecting logs`);
}
return;
}
CloudRunnerLogger.log(`Log file exist`);
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 = '';
}
@@ -1,6 +1,6 @@
import * as core from '@actions/core'; import * as core from '@actions/core';
class OrchestratorLogger { class CloudRunnerLogger {
private static timestamp: number; private static timestamp: number;
private static globalTimestamp: number; private static globalTimestamp: number;
@@ -44,4 +44,4 @@ class OrchestratorLogger {
return Date.now(); return Date.now();
} }
} }
export default OrchestratorLogger; export default CloudRunnerLogger;
@@ -1,6 +1,6 @@
import BuildParameters from '../../../build-parameters'; import BuildParameters from '../../../build-parameters';
class OrchestratorResult { class CloudRunnerResult {
public BuildParameters: BuildParameters; public BuildParameters: BuildParameters;
public BuildResults: string; public BuildResults: string;
public BuildSucceeded: boolean; public BuildSucceeded: boolean;
@@ -21,4 +21,4 @@ class OrchestratorResult {
this.LibraryCacheUsed = libraryCacheUsed; this.LibraryCacheUsed = libraryCacheUsed;
} }
} }
export default OrchestratorResult; export default CloudRunnerResult;
@@ -1,9 +1,9 @@
import { exec } from 'child_process'; import { exec } from 'child_process';
import { RemoteClientLogger } from '../../remote-client/remote-client-logger'; import { RemoteClientLogger } from '../../remote-client/remote-client-logger';
export class OrchestratorSystem { export class CloudRunnerSystem {
public static async RunAndReadLines(command: string): Promise<string[]> { public static async RunAndReadLines(command: string): Promise<string[]> {
const result = await OrchestratorSystem.Run(command, false, true); const result = await CloudRunnerSystem.Run(command, false, true);
return result return result
.split(`\n`) .split(`\n`)

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