Compare commits

..

53 Commits

Author SHA1 Message Date
frostebite
936ca76c4e fix: revert Node version to 18 in CI workflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 08:02:32 +00:00
frostebite
5f63107fa7 fix(ci): bump node version to 20 in integrity-check
yargs@18.0.0 requires Node >=20.19.0, so Node 18 is no longer
compatible.

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:55:50 +00:00
frostebite
49c3bcf0a5 fix(ci): restore missing k3d cluster step in orchestrator-integrity
A mangled comment merged the "Create k3d cluster" step header with
the previous yarn install step, causing a duplicate `run` key that
prevented the entire Integrity workflow from loading.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:19:40 +00:00
frostebite
c08d13e3a5 Merge remote-tracking branch 'origin/feature/orchestrator-enterprise-support' into release/lts-infrastructure
Merges latest commits from PR #777 (git hooks fix + 129 new tests).
Resolved merge conflicts:
- dist/index.js, dist/index.js.map: rebuilt from merged source
- src/model/input.ts: fixed 3 missing closing braces from merge
- src/model/orchestrator/options/orchestrator-options.ts: fixed 1 missing closing brace from merge
2026-03-06 09:37:25 +00:00
frostebite
f05cfe7036 chore: add integration branch update scripts for release/lts-infrastructure 2026-03-05 23:32:36 +00:00
frostebite
18e20aaff1 Merge remote-tracking branch 'origin/ci/orchestrator-integrity-speedup' into release/lts-infrastructure
# Conflicts:
#	.github/workflows/orchestrator-integrity.yml
#	dist/index.js.map
2026-03-05 23:31:40 +00:00
frostebite
52a5bc4a6d Merge remote-tracking branch 'origin/feature/ci-platform-providers' into release/lts-infrastructure
# Conflicts:
#	action.yml
#	dist/index.js
#	dist/index.js.map
#	src/model/build-parameters.ts
#	src/model/input.ts
#	src/model/orchestrator/orchestrator.ts
2026-03-05 23:31:13 +00:00
frostebite
67fd293725 Merge remote-tracking branch 'origin/feature/cloud-run-azure-providers' into release/lts-infrastructure
# Conflicts:
#	action.yml
#	dist/index.js.map
#	src/model/input.ts
#	src/model/orchestrator/providers/provider-loader.ts
2026-03-05 23:31:03 +00:00
frostebite
f77a1350e6 Merge remote-tracking branch 'origin/feature/premade-secret-sources' into release/lts-infrastructure
# Conflicts:
#	action.yml
#	dist/index.js
#	dist/index.js.map
2026-03-05 23:30:53 +00:00
frostebite
7307bea200 Merge remote-tracking branch 'origin/feature/provider-load-balancing' into release/lts-infrastructure
# Conflicts:
#	dist/index.js.map
#	src/model/build-parameters.ts
#	src/model/orchestrator/options/orchestrator-options.ts
2026-03-05 23:30:45 +00:00
frostebite
3e1547170b Merge remote-tracking branch 'origin/feature/build-reliability' into release/lts-infrastructure
# Conflicts:
#	action.yml
#	dist/index.js
#	dist/index.js.map
#	src/model/build-parameters.ts
#	src/model/input.ts
2026-03-05 23:30:33 +00:00
frostebite
2ef2275ae3 Merge remote-tracking branch 'origin/feature/orchestrator-unit-tests' into release/lts-infrastructure
# Conflicts:
#	.github/workflows/orchestrator-integrity.yml
#	dist/index.js.map
2026-03-05 23:30:24 +00:00
frostebite
6c548cd3f7 Merge remote-tracking branch 'origin/fix/secure-git-token-usage' into release/lts-infrastructure
# Conflicts:
#	dist/index.js.map
2026-03-05 23:30:10 +00:00
frostebite
02d4ec0dd2 Merge origin/feature/orchestrator-enterprise-support into release/lts-infrastructure 2026-03-05 23:30:01 +00:00
frostebite
b4ffa3e070 ci: split orchestrator integrity into 4 parallel jobs to fix timeout
The monolithic orchestrator-integrity workflow runs 25+ tests sequentially
in a single job, consistently hitting the 60-minute timeout on PR runs.
Split into 4 parallel jobs (k8s, aws-provider, local-docker, rclone) each
on its own runner, cutting wall-clock time from 3+ hours to ~1 hour and
eliminating disk space exhaustion from shared runner contention.

Adopts the parallel architecture from PR #809.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:43:59 +00:00
frostebite
81ba9c38af style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:16:01 +00:00
frostebite
79ae55802d style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:14:40 +00:00
frostebite
e9c247f04f style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:10:24 +00:00
frostebite
3976b7cedd style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:08:56 +00:00
frostebite
9789eb5c3b ci: split orchestrator integrity into parallel jobs for faster validation
Rewrite the monolith orchestrator-integrity.yml (1110 lines, single job,
3+ hour sequential execution) into 4 parallel jobs that run on separate
runners:

- k8s-tests: k3d cluster + LocalStack, 5 tests
- aws-provider-tests: LocalStack only, 10 tests
- local-docker-tests: Docker + LocalStack for S3 tests, 9 tests
- rclone-tests: rclone + LocalStack, 1 test

Key improvements:
- Wall-clock time drops from ~3h to ~1h (longest single job)
- Disk exhaustion eliminated: each job gets its own fresh 14GB runner
- Cleanup logic deduplicated via sourced shell functions instead of
  15 copy-pasted 30-line blocks
- K3d node image cleanup only runs in the k8s job (where it matters)
- Light cleanup (cache + docker prune -f) between tests; heavy cleanup
  (prune -af --volumes) only at job boundaries
- workflow_call interface unchanged; integrity-check.yml needs no changes

Ref: #794

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:40:48 +00:00
frostebite
b3bd405399 fix: prettier formatting for orchestrator-folders-auth test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:16:05 +00:00
frostebite
4d7e8717e9 chore: rebuild dist for provider timeout and credential fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:05:02 +00:00
frostebite
120c3c5b24 fix(providers): add polling timeouts, fix credential parsing, validate dependencies
- GitHub Actions: max 4-hour polling with clear timeout error including run URL
- GitLab CI: max 4-hour polling with clear timeout error including pipeline URL
- Remote PowerShell: fix credential split to preserve passwords with colons
  (split on first colon only instead of all colons)
- Remote PowerShell: throw clear error when credential format is invalid
- Ansible: validate ansible-playbook binary exists in setupWorkflow
  (separate from ansible --version check)
- All timeout errors use core.error() for GitHub Actions annotation visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:04:15 +00:00
frostebite
40dd436000 fix(reliability): add disk space validation before build archival
Check available disk space (cross-platform: wmic/df) before archive
operations to prevent data loss on full disks. Skip archival with
warning if insufficient space (10% safety margin). Clean up partial
archives on tar failure. Proceed with warning when space check fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:00:20 +00:00
frostebite
cff759721a fix(load-balancing): add pagination limits and rate-limit detection
Cap pagination at 100 pages (10,000 runners max), detect GitHub API
rate limiting (403/429) with reset time reporting, add 30-second total
timeout for pagination loop. Log clear diagnostic when no runners found
suggesting possible causes (token permissions, runner registration).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:00:17 +00:00
frostebite
f06f99b3e5 chore: rebuild dist for cli-provider timeout changes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:55:23 +00:00
frostebite
1f3affe097 fix(secrets): prevent shell injection in secret key names and mask values
- Validate secret key names against alphanumeric allowlist before shell interpolation
- Apply validation in both SecretSourceService.fetchSecret() and legacy queryOverride()
- Mask fetched secret values with core.setSecret() to prevent log exposure
- Add 20 new tests for validation and masking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:55:18 +00:00
frostebite
fe63d7b32d fix(cli-provider): add timeout protection for external CLI processes
Prevent builds from hanging indefinitely when CLI provider subprocess
is unresponsive. Default 2h for runTaskInWorkflow, 1h for watchWorkflow.
Graceful SIGTERM with 10s grace before SIGKILL.

- Added RUN_TASK_TIMEOUT_MS (2 hours) and WATCH_WORKFLOW_TIMEOUT_MS (1 hour)
- Added gracefulKill helper: SIGTERM first, SIGKILL after 10s grace period
- runTaskInWorkflow and watchWorkflow now have timeout protection
- Existing execute() method upgraded to use gracefulKill
- core.error() called with clear human-readable timeout message
- Added comprehensive tests: timeout triggers, SIGKILL escalation,
  grace period cancellation on voluntary exit, normal completion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:55:14 +00:00
frostebite
007852a800 feat(cache): add child workspace isolation for multi-product CI builds (#777)
Implement two-level workspace isolation pattern for enterprise-scale CI:
- Atomic O(1) workspace restore via filesystem move (no tar/download/extract)
- Separate Library caching for independent restore
- .git preservation for delta operations
- Stale workspace cleanup with configurable retention policies
- 5 new action inputs: childWorkspacesEnabled, childWorkspaceName,
  childWorkspaceCacheRoot, childWorkspacePreserveGit,
  childWorkspaceSeparateLibrary
- 28 unit tests covering all service methods

This enables enterprise CI where workspaces are 50GB+ and traditional
caching via actions/cache is impractical. On NTFS, workspace restore
is O(1) via atomic rename when source and destination are on the same volume.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:02:03 +00:00
frostebite
ff56194b30 test(providers): add comprehensive unit tests for GitHub Actions, GitLab CI, PowerShell, and Ansible providers (#806)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:47:14 +00:00
frostebite
47670cf3ce feat(reliability): implement build reliability service with git integrity, reserved filename cleanup, and build archival
Adds BuildReliabilityService with the following capabilities:
- checkGitIntegrity(): runs git fsck --no-dangling and parses output for corruption
- cleanStaleLockFiles(): removes stale .lock files older than 10 minutes
- validateSubmoduleBackingStores(): validates .git files point to valid backing stores
- recoverCorruptedRepo(): orchestrates fsck, lock cleanup, re-fetch, retry fsck
- cleanReservedFilenames(): removes Windows reserved filenames (con, prn, aux, nul, com1-9, lpt1-9)
- archiveBuildOutput(): creates tar.gz archive of build output
- enforceRetention(): deletes archives older than retention period
- configureGitEnvironment(): sets GIT_TERMINAL_PROMPT=0, http.postBuffer, core.longpaths

Wired into action.yml as opt-in inputs, with pre-build integrity checks and
post-build archival in the main entry point.

Includes 29 unit tests covering success and failure cases for all methods.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:44:58 +00:00
frostebite
4f07508484 feat(orchestrator): build reliability features — git integrity, reserved filename cleanup, archival
Add three optional reliability features for hardening CI pipelines:
- Git corruption detection & recovery (fsck, stale lock cleanup,
  submodule backing store validation, auto-recovery)
- Reserved filename cleanup (removes Windows device names that
  cause Unity asset importer infinite loops)
- Build output archival with configurable retention policy

All features are opt-in and fail gracefully with warnings only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:35:57 +00:00
frostebite
7db70a712f style: fix prettier formatting and eslint errors on test files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:23:05 +00:00
frostebite
12f287168d feat(orchestrator): CI platform providers — Remote PowerShell, GitHub Actions, GitLab CI, Ansible
Add four new providers that delegate builds to external CI platforms:
- remote-powershell: Execute on remote machines via WinRM/SSH
- github-actions: Dispatch workflow_dispatch on target repository
- gitlab-ci: Trigger pipeline via GitLab API
- ansible: Run playbooks against managed inventory

Each follows the CI-as-a-provider pattern: trigger remote job,
pass build parameters, stream logs, report status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:16:36 +00:00
frostebite
26903e96dd feat(hooks): add Unity Git Hooks integration and runHookGroups
Built-in support for Unity Git Hooks (com.frostebite.unitygithooks):
- Auto-detect UPM package in Packages/manifest.json
- Run init-unity-lefthook.js before hook installation
- Set CI-friendly env vars (disable background project mode)

New gitHooksRunBeforeBuild input runs specific lefthook groups before
the Unity build, allowing CI to trigger pre-commit or pre-push checks
that normally only fire on git events.

35 unit tests covering detection, init, CI env, group execution, and
failure handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:54:07 +00:00
frostebite
cf3478c8ec feat(lfs): add built-in elastic-git-storage support with auto-install
First-class support for elastic-git-storage as a custom LFS transfer
agent. When lfsTransferAgent is set to "elastic-git-storage" (or
"elastic-git-storage@v1.0.0" for a specific version), the service
automatically finds or installs the agent from GitHub releases, then
configures it via git config.

Supports version pinning via @version suffix in the agent value,
eliminating the need for a separate version parameter. Platform and
architecture detection handles linux/darwin/windows on amd64/arm64.

37 unit tests covering detection, PATH lookup, installation, version
parsing, and configuration delegation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:46:22 +00:00
frostebite
7f895304f4 feat(secrets): add HashiCorp Vault as first-class premade secret source
Adds three Vault entries: hashicorp-vault (KV v2), hashicorp-vault-kv1
(KV v1), and vault (short alias). Uses VAULT_ADDR for server address and
VAULT_MOUNT env var for configurable mount path (defaults to 'secret').

Refs #776

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:41:04 +00:00
frostebite
e4c156e7b0 feat(orchestrator): add premade secret sources and YAML definitions
Add SecretSourceService with premade secret source integrations:
- aws-secrets-manager (with --query SecretString for direct value)
- aws-parameter-store (with --with-decryption)
- gcp-secret-manager (latest version)
- azure-key-vault (via $AZURE_VAULT_NAME env var)
- env (environment variables, no shell command needed)
- Custom commands (any string with {0} placeholder)
- YAML file definitions for custom sources

Add secretSource input that takes precedence over inputPullCommand.
Backward compatible — existing inputPullCommand behavior unchanged.

Closes #776

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:35:03 +00:00
frostebite
8a41533779 fix(orchestrator): use http.extraHeader for secure git authentication
Replace token-in-URL pattern with http.extraHeader for git clone and LFS
operations. The token no longer appears in clone URLs, git remote config,
or process command lines.

Add gitAuthMode input (default: 'header', legacy: 'url') so users can
fall back to the old behavior if needed.

Closes #785

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:30:51 +00:00
frostebite
a0c79bd657 test(orchestrator): expand unit tests for enterprise services
Add comprehensive tests for CLI provider (cleanupWorkflow, garbageCollect,
listWorkflow, watchWorkflow, stderr forwarding, timeout handling), local
cache service (saveLfsCache full path and error handling), git hooks service
(husky install, failure logging, edge cases), and LFS agent service (empty
storagePaths, validate logging). 73 tests across 4 test files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:20:31 +00:00
frostebite
f4451060a7 ci(orchestrator): add fast unit test gate to integrity workflow
Adds a fast-fail unit test step at the top of orchestrator-integrity,
right after yarn install and before any infrastructure setup (k3d,
LocalStack). Runs 113 mock-based orchestrator tests in ~5 seconds.

If serialization, path computation, log parsing, or provider loading
is broken, the workflow fails immediately instead of spending 30+
minutes setting up LocalStack and k3d clusters.

Tests included: orchestrator-guid, orchestrator-folders,
task-parameter-serializer, follow-log-stream-service,
runner-availability-service, provider-url-parser, provider-loader,
provider-git-manager, orchestrator-image, orchestrator-hooks,
orchestrator-github-checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:13:49 +00:00
frostebite
17a0ea3776 test(orchestrator): add unit tests for untested core services
Adds 64 new mock-based unit tests covering orchestrator services that
previously had zero test coverage:

- TaskParameterSerializer: env var format conversion, round-trip,
  uniqBy deduplication, blocked params, default secrets
- FollowLogStreamService: build output message parsing — end of
  transmission, build success/failure detection, error accumulation,
  Library rebuild detection
- OrchestratorNamespace (guid): GUID generation format, platform
  name normalization, nanoid uniqueness
- OrchestratorFolders: path computation for all folder getters,
  ToLinuxFolder conversion, repo URL generation, purge flag detection

All tests are pure mock-based and run without any external
infrastructure (no LocalStack, K8s, Docker, or AWS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:08:49 +00:00
frostebite
7e9d0bf53e test(orchestrator): add runner availability service tests
Covers: no token skip, no runners fallback, busy/offline runners,
label filtering (case-insensitive), minAvailable threshold,
fail-open on API error, mixed runner states.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:58:58 +00:00
frostebite
cfac5f138d test(orchestrator): expand local cache service test coverage
Adds tests for cache hit restore (picks latest tar), LFS cache
restore/save, garbage collection age filtering, and edge cases
like permission errors and empty directories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:58:09 +00:00
frostebite
d17b099593 style: format changed files with prettier
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:52:11 +00:00
frostebite
8194790728 feat(orchestrator): add retry-on-fallback and provider init timeout
Adds retryOnFallback (retry failed builds on alternate provider) and
providerInitTimeout (swap provider if init takes too long). Refactors
run() into run()/runWithProvider() to support retry loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:45:54 +00:00
frostebite
786ee3799c feat(orchestrator): automatic provider fallback with runner availability check
Adds built-in load balancing: check GitHub runner availability before
builds start, auto-route to a fallback provider when runners are busy
or offline. Eliminates the need for a separate check-runner job.

New inputs: fallbackProviderStrategy, runnerCheckEnabled,
runnerCheckLabels, runnerCheckMinAvailable.

Outputs providerFallbackUsed and providerFallbackReason for workflow
visibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:39:23 +00:00
frostebite
f4bc5d20c4 feat(orchestrator): multi-storage support for GCP and Azure providers
Both providers now support four storage backends via gcpStorageType / azureStorageType:

GCP Cloud Run:
  - gcs-fuse: Mount GCS bucket as POSIX filesystem (unlimited, 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, volatile, up to 32 GiB)

Azure ACI:
  - 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, volatile, limited by container memory)

New inputs: gcpStorageType, gcpFilestoreIp, gcpFilestoreShare, azureStorageType,
azureBlobContainer. Constructor validates storage config and warns on missing
prerequisites (e.g. NFS requires VPC connector/subnet).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:07:20 +00:00
frostebite
d8563369e1 feat(orchestrator): add experimental GCP Cloud Run and Azure ACI providers
Add two new cloud provider implementations for the orchestrator, both marked
as experimental:

- **GCP Cloud Run Jobs** (`providerStrategy: gcp-cloud-run`): Executes Unity
  builds as Cloud Run Jobs with GCS FUSE for large artifact storage. Supports
  configurable machine types, service accounts, and VPC connectors. 7 new inputs
  (gcpProject, gcpRegion, gcpBucket, gcpMachineType, gcpDiskSizeGb,
  gcpServiceAccount, gcpVpcConnector).

- **Azure Container Instances** (`providerStrategy: azure-aci`): Executes Unity
  builds as ACI containers with Azure File Shares (Premium FileStorage) for
  large artifact storage up to 100 TiB. Supports configurable CPU/memory,
  VNet integration, and subscription targeting. 9 new inputs
  (azureResourceGroup, azureLocation, azureStorageAccount, azureFileShareName,
  azureSubscriptionId, azureCpu, azureMemoryGb, azureDiskSizeGb, azureSubnetId).

Both providers use their respective CLIs (gcloud, az) for infrastructure
management and support garbage collection of old build resources. No tests
included as these require real cloud infrastructure to validate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:00:09 +00:00
frostebite
5268630ef0 feat(orchestrator): enterprise feature support — CLI provider, submodule profiles, caching, LFS, hooks
Add generic enterprise-grade features to the orchestrator, enabling Unity projects with
complex CI/CD pipelines to adopt game-ci/unity-builder with built-in support for:

- CLI provider protocol: JSON-over-stdin/stdout bridge enabling providers in any language
  (Go, Python, Rust, shell) via the `providerExecutable` input
- Submodule profiles: YAML-based selective submodule initialization with glob patterns
  and variant overlays (`submoduleProfilePath`, `submoduleVariantPath`)
- Local build caching: Filesystem-based Library and LFS caching for local builds without
  external cache actions (`localCacheEnabled`, `localCacheRoot`)
- Custom LFS transfer agents: Register external transfer agents like elastic-git-storage
  (`lfsTransferAgent`, `lfsTransferAgentArgs`, `lfsStoragePaths`)
- Git hooks support: Detect and install lefthook/husky with configurable skip lists
  (`gitHooksEnabled`, `gitHooksSkipList`)

Also removes all `orchestrator-develop` branch references, replacing with `main`.

13 new action inputs, 13 new files, 14 new CLI provider tests, 17 submodule tests,
plus cache/LFS/hooks unit tests. All 452 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:54:46 +00:00
216 changed files with 370057 additions and 77961 deletions

4
.eslintignore Normal file
View File

@@ -0,0 +1,4 @@
dist/
lib/
node_modules/
jest.config.js

90
.eslintrc.json Normal file
View File

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

View File

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

View File

@@ -18,9 +18,9 @@ jobs:
projectPath:
- test-project
unityVersion:
- 2021.3.45f2
- 2022.3.62f3
- 2023.2.22f1
- 2021.3.45f1
- 2022.3.13f1
- 2023.2.2f1
targetPlatform:
- StandaloneOSX # Build a MacOS executable
- iOS # Build an iOS executable

View File

@@ -9,7 +9,8 @@ concurrency:
cancel-in-progress: true
env:
UNITY_LICENSE: "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root>\n <License
UNITY_LICENSE:
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><root>\n <License
id=\"Terms\">\n <MachineBindings>\n <Binding Key=\"1\"
Value=\"576562626572264761624c65526f7578\"/>\n <Binding Key=\"2\"
Value=\"576562626572264761624c65526f7578\"/>\n </MachineBindings>\n <MachineID
@@ -35,7 +36,8 @@ env:
jobs:
buildForAllPlatformsUbuntu:
name: "${{ matrix.targetPlatform }} on ${{ matrix.unityVersion}}${{startsWith(matrix.buildProfile, 'Assets') && ' (via Build Profile)' || '' }}"
name:
"${{ matrix.targetPlatform }} on ${{ matrix.unityVersion}}${{startsWith(matrix.buildProfile, 'Assets') && ' (via Build Profile)' || '' }}"
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -46,9 +48,9 @@ jobs:
projectPath:
- test-project
unityVersion:
- 2021.3.45f2
- 2022.3.62f3
- 2023.2.22f1
- 2021.3.32f1
- 2022.3.13f1
- 2023.2.2f1
targetPlatform:
- StandaloneOSX # Build a macOS standalone (Intel 64-bit) with mono backend.
- StandaloneWindows64 # Build a Windows 64-bit standalone with mono backend.
@@ -198,6 +200,7 @@ jobs:
###########################
- uses: actions/upload-artifact@v4
with:
name: "Build ${{ matrix.targetPlatform }}${{ startsWith(matrix.buildProfile, 'Assets') && ' (via Build Profile)' || '' }} on Ubuntu (${{ matrix.unityVersion }}_il2cpp_${{ matrix.buildWithIl2cpp }}_params_${{ matrix.additionalParameters }})"
name:
"Build ${{ matrix.targetPlatform }}${{ startsWith(matrix.buildProfile, 'Assets') && ' (via Build Profile)' || '' }} on Ubuntu (${{ matrix.unityVersion }}_il2cpp_${{ matrix.buildWithIl2cpp }}_params_${{ matrix.additionalParameters }})"
path: build
retention-days: 14

View File

@@ -18,9 +18,9 @@ jobs:
projectPath:
- test-project
unityVersion:
- 2021.3.45f2
- 2022.3.62f3
- 2023.2.22f1
- 2021.3.32f1
- 2022.3.13f1
- 2023.2.2f1
targetPlatform:
- Android # Build an Android apk.
- StandaloneWindows64 # Build a Windows 64-bit standalone.
@@ -39,7 +39,7 @@ jobs:
- unityVersion: 6000.0.36f1
targetPlatform: StandaloneWindows64
buildProfile: 'Assets/Settings/Build Profiles/Sample Windows Build Profile.asset'
steps:
###########################
# Checkout #
@@ -66,34 +66,6 @@ jobs:
run: |
Move-Item -Path "./test-project/ProjectSettings/ProjectSettingsIl2cpp.asset" -Destination "./test-project/ProjectSettings/ProjectSettings.asset" -Force
###########################
# Docker Readiness #
###########################
- name: Ensure Docker daemon is ready
timeout-minutes: 2
shell: powershell
run: |
$maxRetries = 10
$retryDelay = 6
for ($i = 0; $i -lt $maxRetries; $i++) {
$svc = Get-Service docker -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq 'Running') {
docker version 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host "Docker is ready."
exit 0
}
}
if ($svc -and $svc.Status -eq 'Stopped') {
Write-Host "Docker service stopped, attempting to start..."
Start-Service docker -ErrorAction SilentlyContinue
}
Write-Host "Waiting for Docker daemon (attempt $($i+1)/$maxRetries)..."
Start-Sleep -Seconds $retryDelay
}
Write-Error "Docker daemon did not start within $($maxRetries * $retryDelay) seconds"
exit 1
###########################
# Build #
###########################
@@ -174,8 +146,6 @@ jobs:
###########################
- uses: actions/upload-artifact@v4
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' || '' }}${{ matrix.buildProfile && ' With Build Profile' || '' }}
path: build
retention-days: 14

View File

@@ -2,8 +2,7 @@ name: Integrity
on:
push: { branches: [main] }
pull_request:
types: [opened, synchronize, reopened, labeled]
pull_request: {}
permissions:
contents: read
@@ -23,40 +22,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install package manager (from package.json)
run: |
corepack enable
corepack install
- uses: actions/setup-node@v4
with:
node-version: '18'
- name: Resolve yarn cache folder
id: yarn-config
run: echo "cacheFolder=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- name: Restore yarn install cache (node_modules + cacheFolder + install-state)
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-config.outputs.cacheFolder }}
.yarn/install-state.gz
key: yarn-v2-${{ runner.os }}-node-18-${{ hashFiles('yarn.lock') }}
restore-keys: |
yarn-v2-${{ runner.os }}-node-18-
- name: Install deps
env:
YARN_ENABLE_HARDENED_MODE: 'false'
run: |
case "$(yarn --version)" in 1.*) echo 'expected up-to-date yarn version'; exit 1 ;; esac
yarn install --immutable
- run: yarn
- run: yarn lint
- run: yarn test:ci --coverage
- run: bash <(curl -s https://codecov.io/bash)
- 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; }
orchestrator-integration:
name: Orchestrator Integration
if: >-
github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'run-integration')
uses: ./.github/workflows/validate-orchestrator-integration.yml
orchestrator:
name: Orchestrator Integrity
uses: ./.github/workflows/orchestrator-integrity.yml
secrets: inherit

View File

@@ -0,0 +1,61 @@
name: Async Checks API
on:
workflow_dispatch:
inputs:
checksObject:
description: ''
required: false
default: ''
permissions:
checks: write
env:
GKE_ZONE: 'us-central1'
GKE_REGION: 'us-central1'
GKE_PROJECT: 'unitykubernetesbuilder'
GKE_CLUSTER: 'game-ci-github-pipelines'
GCP_LOGGING: true
GCP_PROJECT: unitykubernetesbuilder
GCP_LOG_FILE: ${{ github.workspace }}/orchestrator-logs.txt
# Commented out: Using LocalStack tests instead of real AWS
# AWS_REGION: eu-west-2
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_DEFAULT_REGION: eu-west-2
# AWS_STACK_NAME: game-ci-github-pipelines
ORCHESTRATOR_BRANCH: ${{ github.ref }}
ORCHESTRATOR_DEBUG: true
ORCHESTRATOR_DEBUG_TREE: true
DEBUG: true
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
PROJECT_PATH: test-project
UNITY_VERSION: 2019.3.15f1
USE_IL2CPP: false
jobs:
asyncChecks:
name: Async Checks
if: github.event.event_type != 'pull_request_target'
runs-on: ubuntu-latest
steps:
- timeout-minutes: 180
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
PROJECT_PATH: test-project
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_PRIVATE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_PLATFORM: StandaloneWindows64
orchestratorTests: true
versioning: None
ORCHESTRATOR_CLUSTER: local-docker
# Commented out: Using LocalStack tests instead of real AWS
# AWS_STACK_NAME: game-ci-github-pipelines
CHECKS_UPDATE: ${{ github.event.inputs.checksObject }}
run: |
git clone -b main https://github.com/game-ci/unity-builder
cd unity-builder
yarn
ls
yarn run cli -m checks-update

View File

@@ -1,91 +0,0 @@
name: Sync Secrets to Repositories
on:
workflow_dispatch:
inputs:
target_repo:
description: 'Target repository (org/repo format)'
required: true
default: 'game-ci/orchestrator'
type: choice
options:
- game-ci/orchestrator
- game-ci/cli
dry_run:
description: 'Dry run (list secrets to sync without writing)'
required: false
default: false
type: boolean
permissions:
contents: read
jobs:
sync-secrets:
name: Sync secrets to ${{ inputs.target_repo }}
runs-on: ubuntu-latest
steps:
- name: Sync secrets
env:
GH_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
TARGET_REPO: ${{ inputs.target_repo }}
DRY_RUN: ${{ inputs.dry_run }}
# Secrets to sync — values come from repo + org secrets available here
SECRET_UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
SECRET_UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
SECRET_UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
SECRET_GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
SECRET_GOOGLE_SERVICE_ACCOUNT_EMAIL: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_EMAIL }}
SECRET_GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
SECRET_CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
SECRET_UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
SECRET_NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
SECRETS=(
"UNITY_EMAIL:SECRET_UNITY_EMAIL"
"UNITY_PASSWORD:SECRET_UNITY_PASSWORD"
"UNITY_SERIAL:SECRET_UNITY_SERIAL"
"UNITY_LICENSE:SECRET_UNITY_LICENSE"
"GIT_PRIVATE_TOKEN:SECRET_GIT_PRIVATE_TOKEN"
"GOOGLE_SERVICE_ACCOUNT_EMAIL:SECRET_GOOGLE_SERVICE_ACCOUNT_EMAIL"
"GOOGLE_SERVICE_ACCOUNT_KEY:SECRET_GOOGLE_SERVICE_ACCOUNT_KEY"
"CODECOV_TOKEN:SECRET_CODECOV_TOKEN"
"NPM_TOKEN:SECRET_NPM_TOKEN"
)
synced=0
skipped=0
for entry in "${SECRETS[@]}"; do
name="${entry%%:*}"
env_var="${entry##*:}"
value="${!env_var}"
if [ -z "$value" ]; then
echo "⏭ SKIP: $name (not available in this repo's context)"
skipped=$((skipped + 1))
continue
fi
if [ "$DRY_RUN" = "true" ]; then
echo "🔍 DRY RUN: would sync $name → $TARGET_REPO"
else
if echo "$value" | gh secret set "$name" -R "$TARGET_REPO" --body - 2>/dev/null; then
echo "✅ SYNCED: $name → $TARGET_REPO"
else
echo "⚠️ FAILED: $name → $TARGET_REPO (continuing)"
skipped=$((skipped + 1))
synced=$((synced - 1))
fi
fi
synced=$((synced + 1))
done
echo ""
echo "=== Summary ==="
echo "Synced: $synced"
echo "Skipped (not available): $skipped"
echo "Target: $TARGET_REPO"
if [ "$DRY_RUN" = "true" ]; then
echo "Mode: DRY RUN (no secrets were written)"
fi

View File

@@ -1,205 +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
python3 -c "
import sys, json
manifest = json.load(sys.stdin)
manifest['dependencies']['${{ matrix.name }}'] = '${{ matrix.package }}'
json.dump(manifest, sys.stdout, indent=2)
" < Packages/manifest.json > Packages/manifest.tmp && mv Packages/manifest.tmp Packages/manifest.json
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 }}"
echo ""
if [ "$STATUS" = "success" ]; then
echo "✅ **PASSED** — Compiled and built successfully"
else
echo "❌ **FAILED** — Build or compilation failed"
fi
echo ""
echo "- Unity: ${{ matrix.unity }}"
echo "- Platform: ${{ matrix.platform }}"
echo "- Source: ${{ matrix.source }}"
echo "- Package: \`${{ matrix.package }}\`"
} >> "$GITHUB_STEP_SUMMARY"
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']
});
}
}

View File

@@ -1,255 +0,0 @@
name: Validate Orchestrator Compatibility
# ==============================================================================
# Essential plugin health checks — runs on every PR and push.
# Fast (~5 min): compilation, unit tests, plugin interface, type declarations.
#
# For exhaustive integration tests (k8s, AWS, local-docker, rclone) see
# validate-orchestrator-integration.yml which runs on a daily cron.
# ==============================================================================
on:
workflow_dispatch:
push:
branches: [main, 'release/**', 'feature/**', 'refactor/**']
paths:
- 'src/model/orchestrator-plugin.ts'
- 'src/model/build-parameters.ts'
- 'src/model/input.ts'
- 'src/model/github.ts'
- 'src/model/cli/cli.ts'
- 'src/model/input-readers/**'
- 'src/index.ts'
- 'src/types/game-ci-orchestrator.d.ts'
- 'action.yml'
- 'package.json'
- 'yarn.lock'
- '.github/workflows/validate-orchestrator.yml'
pull_request:
branches: [main, 'release/**']
paths:
- 'src/model/orchestrator-plugin.ts'
- 'src/model/build-parameters.ts'
- 'src/model/input.ts'
- 'src/model/github.ts'
- 'src/model/cli/cli.ts'
- 'src/model/input-readers/**'
- 'src/index.ts'
- 'src/types/game-ci-orchestrator.d.ts'
- 'action.yml'
- 'package.json'
- 'yarn.lock'
- '.github/workflows/validate-orchestrator.yml'
permissions:
contents: read
packages: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# ============================================================================
# PLUGIN ARCHITECTURE HEALTH CHECK
# ============================================================================
# Validates that:
# 1. unity-builder compiles and its unit tests pass
# 2. Plugin loader degrades gracefully without orchestrator
# 3. Orchestrator compiles and its unit tests pass
# 4. Plugin loader loads all services when orchestrator is installed
# 5. Type declarations match actual exports
# ============================================================================
plugin-health:
name: Plugin Architecture Health
runs-on: ubuntu-latest
steps:
- name: Checkout unity-builder
uses: actions/checkout@v4
- name: Checkout orchestrator
uses: actions/checkout@v4
with:
repository: game-ci/orchestrator
ref: ${{ github.head_ref || github.ref_name }}
path: orchestrator-standalone
continue-on-error: true
id: orchestrator-branch
- name: Fallback to orchestrator main branch
if: steps.orchestrator-branch.outcome == 'failure'
uses: actions/checkout@v4
with:
repository: game-ci/orchestrator
path: orchestrator-standalone
- name: Install package manager (from package.json)
run: |
corepack enable
corepack install
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Resolve yarn cache folder
id: yarn-config
run: echo "cacheFolder=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- name: Restore yarn install cache (node_modules + cacheFolder + install-state)
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-config.outputs.cacheFolder }}
.yarn/install-state.gz
key: yarn-v2-${{ runner.os }}-node-20-${{ hashFiles('yarn.lock') }}
restore-keys: |
yarn-v2-${{ runner.os }}-node-20-
# --- unity-builder compilation and tests ---
- name: Install unity-builder dependencies
env:
YARN_ENABLE_HARDENED_MODE: 'false'
run: |
case "$(yarn --version)" in 1.*) echo 'expected up-to-date yarn version'; exit 1 ;; esac
yarn install --immutable
- name: Build unity-builder
run: |
echo "Building unity-builder TypeScript..."
npx tsc
echo "✓ unity-builder compiles successfully"
- name: Run orchestrator-plugin unit tests
run: |
echo "Running orchestrator-plugin unit tests..."
yarn vitest run orchestrator-plugin
# --- Plugin loader without orchestrator ---
- name: Verify plugin loader returns undefined without orchestrator
run: |
echo "Checking plugin loader handles missing @game-ci/orchestrator..."
node -e "
const { loadOrchestratorPlugin } = require('./lib/model/orchestrator-plugin');
(async () => {
const plugin = await loadOrchestratorPlugin();
if (plugin !== undefined) {
console.error('ERROR: loadOrchestratorPlugin should return undefined when package not installed');
process.exit(1);
}
console.log('✓ loadOrchestratorPlugin() returns undefined when package not installed');
})();
"
- name: Verify orchestrator type declarations exist
run: |
if [ -f "src/types/game-ci-orchestrator.d.ts" ]; then
echo "✓ Type declarations for @game-ci/orchestrator exist"
else
echo "::error::Missing type declarations: src/types/game-ci-orchestrator.d.ts"
exit 1
fi
# --- Orchestrator compilation and tests ---
- name: Build and pack orchestrator
working-directory: orchestrator-standalone
run: |
yarn install --immutable
echo "Building orchestrator..."
npx tsc
echo "✓ orchestrator compiles successfully"
echo "Packing orchestrator as tarball..."
npm pack
- name: Run orchestrator unit tests
working-directory: orchestrator-standalone
run: |
echo "Running orchestrator unit tests..."
yarn vitest run 2>&1 | tail -30
# --- Plugin loader with orchestrator installed ---
- name: Install orchestrator into unity-builder
run: |
echo "Installing orchestrator into unity-builder workspace..."
npm install ./orchestrator-standalone/game-ci-orchestrator-*.tgz --no-save --legacy-peer-deps
- name: Verify plugin loader returns exports with orchestrator installed
run: |
echo "Checking plugin loader returns defined exports..."
node -e "
const { loadOrchestratorPlugin } = require('./lib/model/orchestrator-plugin');
(async () => {
const plugin = await loadOrchestratorPlugin();
if (plugin === undefined) {
console.error('ERROR: loadOrchestratorPlugin should return defined plugin when package is installed');
process.exit(1);
}
const lifecycleMethods = [
'initialize', 'canHandleBuild', 'handleBuild',
'beforeLocalBuild', 'afterLocalBuild', 'handlePostBuild',
];
for (const method of lifecycleMethods) {
if (typeof plugin[method] !== 'function') {
console.error('ERROR: plugin.' + method + ' should be a function, got ' + typeof plugin[method]);
process.exit(1);
}
}
console.log('✓ loadOrchestratorPlugin() returns plugin with all ' + lifecycleMethods.length + ' lifecycle methods');
})();
"
- name: Verify type declarations match orchestrator exports
run: |
echo "Checking type declarations align with orchestrator exports..."
node -e "
const orch = require('@game-ci/orchestrator');
const expectedExports = [
'Orchestrator', 'BuildReliabilityService', 'TestWorkflowService',
'HotRunnerService', 'OutputService', 'OutputTypeRegistry',
'ArtifactUploadHandler', 'IncrementalSyncService',
'ChildWorkspaceService', 'LocalCacheService', 'SubmoduleProfileService',
'LfsAgentService', 'GitHooksService',
];
const missing = expectedExports.filter(e => orch[e] === undefined);
if (missing.length > 0) {
console.error('ERROR: Missing exports from @game-ci/orchestrator:', missing.join(', '));
process.exit(1);
}
console.log('✓ All ' + expectedExports.length + ' declared exports present in orchestrator package');
"
- name: Smoke test orchestrator build wiring
run: |
echo "Verifying orchestrator build wiring end-to-end..."
node -e "
const { loadOrchestratorPlugin } = require('./lib/model/orchestrator-plugin');
(async () => {
// Verify plugin loads successfully with orchestrator installed
const plugin = await loadOrchestratorPlugin();
if (plugin === undefined) {
console.error('ERROR: plugin should be defined when orchestrator is installed');
process.exit(1);
}
// Verify all lifecycle methods are callable
const lifecycleMethods = [
'initialize', 'canHandleBuild', 'handleBuild',
'beforeLocalBuild', 'afterLocalBuild', 'handlePostBuild',
];
for (const m of lifecycleMethods) {
if (typeof plugin[m] !== 'function') {
console.error('ERROR: plugin.' + m + ' should be a function, got ' + typeof plugin[m]);
process.exit(1);
}
}
console.log('✓ Plugin has all ' + lifecycleMethods.length + ' lifecycle methods');
// Verify canHandleBuild returns a boolean
const canHandle = plugin.canHandleBuild();
if (typeof canHandle !== 'boolean') {
console.error('ERROR: canHandleBuild() should return a boolean, got ' + typeof canHandle);
process.exit(1);
}
console.log('✓ canHandleBuild() returns boolean');
console.log('✓ Plugin architecture wiring verified');
})();
"

9
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

2
.prettierignore Normal file
View File

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

7
.prettierrc.json Normal file
View File

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

3
.yarnrc Normal file
View File

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

View File

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

View File

@@ -9,7 +9,8 @@ inputs:
unityVersion:
required: false
default: 'auto'
description: 'Version of unity to use for building the project. Use "auto" to get from your ProjectSettings/ProjectVersion.txt'
description:
'Version of unity to use for building the project. Use "auto" to get from your ProjectSettings/ProjectVersion.txt'
customImage:
required: false
default: ''
@@ -46,10 +47,6 @@ inputs:
required: false
default: ''
description: 'Custom parameters to configure the build.'
useHostNetwork:
required: false
default: false
description: 'Initialises Docker using the host network. (Linux only)'
versioning:
required: false
default: 'Semantic'
@@ -107,13 +104,17 @@ inputs:
gitPrivateToken:
required: false
default: ''
description: 'Github private token to pull from github'
providerStrategy:
default: 'local'
description: '[Orchestrator] Github private token to pull from github'
gitAuthMode:
required: false
default: 'header'
description:
'Build execution strategy. Use "local" for local Docker/Mac builds. For remote builds (aws, k8s, etc.), install
@game-ci/orchestrator and use the game-ci/orchestrator action which declares its own inputs.'
'[Orchestrator] How git authentication is configured. "header" (default) uses http.extraHeader so the token
never appears in clone URLs or git config. "url" embeds the token in clone URLs (legacy behavior).'
githubOwner:
required: false
default: ''
description: '[Orchestrator] GitHub owner name or organization/team name'
runAsHostUser:
required: false
default: 'false'
@@ -123,7 +124,8 @@ inputs:
chownFilesTo:
required: false
default: ''
description: 'User and optionally group (user or user:group or uid:gid) to give ownership of the resulting build artifacts'
description:
'User and optionally group (user or user:group or uid:gid) to give ownership of the resulting build artifacts'
dockerCpuLimit:
required: false
default: ''
@@ -153,7 +155,147 @@ inputs:
allowDirtyBuild:
required: false
default: ''
description: 'Allows the branch of the build to be dirty, and still generate the build.'
description: '[Orchestrator] Allows the branch of the build to be dirty, and still generate the build.'
postBuildSteps:
required: false
default: ''
description:
'[Orchestrator] run a post build job in yaml format with the keys image, secrets (name, value object array),
command string'
preBuildSteps:
required: false
default: ''
description:
'[Orchestrator] 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)'
containerHookFiles:
required: false
default: ''
description:
'[Orchestrator] Specify the names (by file name) of custom steps to run before or after orchestrator jobs, must
match a yaml step file inside your repo in the folder .game-ci/steps/'
customHookFiles:
required: false
default: ''
description:
'[Orchestrator] Specify the names (by file name) of custom hooks to run before or after orchestrator jobs, must
match a yaml step file inside your repo in the folder .game-ci/hooks/'
customCommandHooks:
required: false
default: ''
description: '[Orchestrator] Specify custom commands and trigger hooks (injects commands into jobs)'
customJob:
required: false
default: ''
description:
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with
the keys image, secrets (name, value object array), command line string)'
awsStackName:
default: 'game-ci'
required: false
description: '[Orchestrator] The Cloud Formation stack name that must be setup before using this option.'
providerStrategy:
default: 'local'
required: false
description:
'[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
be configured.'
fallbackProviderStrategy:
default: ''
required: false
description:
'[Orchestrator] Fallback provider when the primary is unavailable. Used with runnerCheckEnabled for automatic
failover, or as a catch-all if the primary provider fails to initialize.'
runnerCheckEnabled:
default: 'false'
required: false
description:
'[Orchestrator] Check GitHub Actions runner availability before starting a build. When no suitable runners are
available and fallbackProviderStrategy is set, automatically routes to the fallback provider.'
runnerCheckLabels:
default: ''
required: false
description:
'[Orchestrator] Comma-separated runner labels to filter when checking availability (e.g. self-hosted,linux).
When empty, checks all runners in the repository.'
runnerCheckMinAvailable:
default: '1'
required: false
description:
'[Orchestrator] Minimum number of idle runners required for the primary provider. If fewer are available,
routes to fallbackProviderStrategy.'
retryOnFallback:
default: 'false'
required: false
description:
'[Orchestrator] When true and fallbackProviderStrategy is set, automatically retry the build on the fallback
provider if the primary provider fails. Useful for long builds where transient cloud failures are common.'
providerInitTimeout:
default: '0'
required: false
description:
'[Orchestrator] Maximum seconds to wait for the primary provider to initialize (setupWorkflow). If exceeded
and fallbackProviderStrategy is set, switches to the fallback. Set to 0 to disable (default).'
secretSource:
default: ''
required: false
description:
'[Orchestrator] Premade secret source for pulling build secrets. Supported values: aws-secrets-manager,
aws-parameter-store, gcp-secret-manager, azure-key-vault, hashicorp-vault, hashicorp-vault-kv1,
vault (alias for hashicorp-vault), env. Can also be a custom shell command with {0} placeholder
for the key, or a path to a YAML file defining custom sources. Takes precedence over
inputPullCommand when set.'
resourceTracking:
default: 'false'
required: false
description: '[Orchestrator] Enable resource tracking logs for disk usage and allocation summaries.'
containerCpu:
default: ''
required: false
description: '[Orchestrator] Amount of CPU time to assign the remote build container'
containerMemory:
default: ''
required: false
description: '[Orchestrator] Amount of memory to assign the remote build container'
readInputFromOverrideList:
default: ''
required: false
description: '[Orchestrator] Comma separated list of input value names to read from "input override command"'
readInputOverrideCommand:
default: ''
required: false
description:
'[Orchestrator] Extend game ci by specifying a command to execute to pull input from external source e.g cloud
provider secret managers'
kubeConfig:
default: ''
required: false
description:
'[Orchestrator] Supply a base64 encoded kubernetes config to run builds on kubernetes and stream logs until
completion.'
kubeVolume:
default: ''
required: false
description: '[Orchestrator] Supply a Persistent Volume Claim name to use for the Unity build.'
kubeStorageClass:
default: ''
required: false
description:
'[Orchestrator] Kubernetes storage class to use for orchestrator jobs, leave empty to install rook cluster.'
kubeVolumeSize:
default: '5Gi'
required: false
description: '[Orchestrator] Amount of disc space to assign the Kubernetes Persistent Volume'
cacheKey:
default: ''
required: false
description: '[Orchestrator] Cache key to indicate bucket for cache'
watchToEnd:
default: 'true'
required: false
description:
'[Orchestrator] 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.'
cacheUnityInstallationOnMac:
default: 'false'
required: false
@@ -178,6 +320,322 @@ inputs:
default: 'false'
required: false
description: 'Skip the activation/deactivation of Unity. This assumes Unity is already activated.'
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.'
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'
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'
outputs:
volume:

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]

View File

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

View File

@@ -0,0 +1,52 @@
# delete-me-update-this-integration-branch.ps1
# Run this script from the repo root while on the release/lts-infrastructure 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-infrastructure') {
Write-Error "Must be on release/lts-infrastructure branch. Currently on: $branchName"
exit 1
}
# Component branches for this integration branch (infrastructure only, no next-gen)
$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'
)
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-infrastructure' to update the remote." -ForegroundColor Cyan
}

386822
dist/index.js generated vendored

File diff suppressed because one or more lines are too long

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

18940
dist/licenses.txt generated vendored

File diff suppressed because it is too large Load Diff

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

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

30
jest.config.js Normal file
View File

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

31
lefthook.yml Normal file
View File

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

View File

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

View File

@@ -7,70 +7,84 @@
"author": "Webber <webber@takken.io>",
"license": "MIT",
"scripts": {
"prepare": "husky",
"prepare": "lefthook install",
"build": "yarn && tsc && ncc build lib --source-map --license licenses.txt",
"test": "node scripts/ensure-husky.mjs && vitest run",
"test:watch": "vitest",
"test:ci": "vitest run",
"coverage": "vitest run --coverage",
"lint": "yarn oxlint --report-unused-disable-directives",
"format": "oxfmt --write",
"format:check": "oxfmt --check",
"typecheck": "tsc --noEmit",
"typecheck:tsgo": "tsgo --noEmit",
"setup:hooks": "node scripts/ensure-husky.mjs"
},
"lint-staged": {
"*.@(ts|tsx|mts|js|jsx|mjs|cjs)": [
"oxlint --fix --quiet",
"oxfmt --write"
],
"*.@(json|jsonc|json5|md|mdx|yaml|yml|css|scss|sass|html|toml)": "oxfmt --write",
".github/workflows/*.@(yml|yaml)": "actionlint"
"lint": "prettier --check \"src/**/*.{js,ts}\" && eslint src/**/*.ts",
"format": "prettier --write \"src/**/*.{js,ts}\"",
"cli": "yarn ts-node src/index.ts -m cli",
"gcp-secrets-tests": "cross-env providerStrategy=aws orchestratorTests=true inputPullCommand=\"gcp-secret-manager\" populateOverride=true pullInputList=UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD yarn test -i -t \"orchestrator\"",
"gcp-secrets-cli": "cross-env orchestratorTests=true USE_IL2CPP=false inputPullCommand=\"gcp-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
"aws-secrets-cli": "cross-env orchestratorTests=true inputPullCommand=\"aws-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
"cli-aws": "cross-env providerStrategy=aws 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": "jest",
"test:ci": "jest --config=jest.ci.config.js --runInBand",
"test-i": "cross-env orchestratorTests=true yarn test -i -t \"orchestrator\"",
"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-k8s": "cross-env orchestratorTests=true providerStrategy=k8s yarn test -i -t \"orchestrator\""
},
"engines": {
"node": ">=18.x"
},
"dependencies": {
"@actions/cache": "^4.1.0",
"@actions/cache": "^4.0.0",
"@actions/core": "^1.11.1",
"@actions/exec": "^1.1.1",
"@actions/github": "^6.0.1",
"commander": "^9.5.0",
"@actions/github": "^6.0.0",
"@aws-sdk/client-cloudformation": "^3.777.0",
"@aws-sdk/client-cloudwatch-logs": "^3.777.0",
"@aws-sdk/client-ecs": "^3.778.0",
"@aws-sdk/client-kinesis": "^3.777.0",
"@aws-sdk/client-s3": "^3.779.0",
"@kubernetes/client-node": "^0.16.3",
"@octokit/core": "^5.1.0",
"async-wait-until": "^2.0.12",
"aws-sdk": "^2.1081.0",
"base-64": "^1.0.0",
"commander": "^9.0.0",
"commander-ts": "^0.2.0",
"kubernetes-client": "^9.0.0",
"md5": "^2.3.0",
"nanoid": "^3.3.12",
"reflect-metadata": "^0.2.2",
"semver": "^7.7.4",
"nanoid": "^3.3.1",
"reflect-metadata": "^0.1.13",
"semver": "^7.5.2",
"shell-quote": "^1.8.3",
"ts-md5": "^1.3.1",
"unity-changeset": "^3.1.0",
"yaml": "^2.8.4"
"uuid": "^9.0.0",
"yaml": "^2.2.2"
},
"devDependencies": {
"@types/base-64": "^1.0.0",
"@types/jest": "^27.4.1",
"@types/node": "^17.0.23",
"@types/semver": "^7.3.9",
"@typescript/native-preview": "^7.0.0-dev.20260505.1",
"@types/uuid": "^9.0.0",
"@typescript-eslint/parser": "4.8.1",
"@vercel/ncc": "^0.36.1",
"@vitest/coverage-istanbul": "^4.1.5",
"cross-env": "^7.0.3",
"eslint": "^10.3.0",
"eslint-plugin-unicorn": "^64.0.0",
"husky": "9",
"eslint": "^7.23.0",
"eslint-config-prettier": "8.1.0",
"eslint-plugin-github": "^4.1.1",
"eslint-plugin-jest": "24.1.3",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-unicorn": "28.0.2",
"jest": "^27.5.1",
"jest-circus": "^27.5.1",
"jest-fail-on-console": "^3.0.2",
"js-yaml": "^4.1.0",
"lint-staged": "^16.4.0",
"lefthook": "^1.6.1",
"node-fetch": "2",
"oxfmt": "^0.48.0",
"oxlint": "^1.63.0",
"prettier": "^2.5.1",
"ts-jest": "^27.1.3",
"ts-node": "10.8.1",
"typescript": "4.7.4",
"vite": "^7",
"vitest": "^4",
"yarn-audit-fix": "^9.3.8"
},
"packageManager": "yarn@4.14.1",
"dependenciesMeta": {
"lefthook": {
"built": true
}
"volta": {
"node": "20.5.1",
"yarn": "1.22.19"
}
}

View File

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

View File

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

View File

@@ -1,278 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
/**
* Integration wiring tests for the plugin lifecycle in index.ts
*
* These tests verify that:
* - The plugin lifecycle hooks are called in the correct order
* - Plugin canHandleBuild() controls the execution path
* - fallbackToLocal is handled correctly
* - When no plugin is installed, local builds still work
* - When providerStrategy is non-local without a plugin, an error is thrown
*/
import { BuildParameters, Docker } from './model';
import * as core from '@actions/core';
// ---------------------------------------------------------------------------
// Mock plugin
// ---------------------------------------------------------------------------
// `vi.mock` hoists to the top of the module, so any factory references must
// be hoisted with `vi.hoisted` to be defined at mock-evaluation time.
const { mockPlugin, mockLoadPlugin } = vi.hoisted(() => {
const plugin = {
initialize: vi.fn().mockResolvedValue(undefined),
canHandleBuild: vi.fn().mockReturnValue(false),
handleBuild: vi.fn().mockResolvedValue({ exitCode: 0 }),
beforeLocalBuild: vi.fn().mockResolvedValue(undefined),
afterLocalBuild: vi.fn().mockResolvedValue(undefined),
handlePostBuild: vi.fn().mockResolvedValue(undefined),
};
return {
mockPlugin: plugin,
mockLoadPlugin: vi.fn().mockResolvedValue(plugin),
};
});
vi.mock('./model/plugin', () => ({
loadPlugin: mockLoadPlugin,
}));
vi.mock('@actions/core');
vi.mock('./model', () => ({
Action: {
checkCompatibility: vi.fn(),
workspace: '/workspace',
actionFolder: '/action',
},
BuildParameters: {
create: vi.fn(),
},
Cache: {
verify: vi.fn(),
},
Docker: {
run: vi.fn().mockResolvedValue(0),
},
// vitest 4 requires constructor mocks to use regular `function` (or
// `class`); arrow fns aren't valid constructors.
ImageTag: vi.fn(function () {
return { toString: () => 'mock-image:latest' };
}),
Output: {
setBuildVersion: vi.fn().mockResolvedValue(''),
setAndroidVersionCode: vi.fn().mockResolvedValue(''),
setEngineExitCode: vi.fn().mockResolvedValue(''),
},
}));
vi.mock('./model/cli/cli', () => ({
Cli: {
InitCliMode: vi.fn().mockReturnValue(false),
},
}));
vi.mock('./model/mac-builder', () => ({
__esModule: true,
default: {
run: vi.fn().mockResolvedValue(0),
},
}));
vi.mock('./model/platform-setup', () => ({
__esModule: true,
default: {
setup: vi.fn().mockResolvedValue(''),
},
}));
const mockedBuildParametersCreate = BuildParameters.create as Mock;
function createMockBuildParameters(overrides: Record<string, any> = {}) {
return {
providerStrategy: 'local',
targetPlatform: 'StandaloneLinux64',
editorVersion: '2021.3.1f1',
buildVersion: '1.0.0',
androidVersionCode: '1',
projectPath: '.',
branch: 'main',
runnerTempPath: '/tmp',
...overrides,
};
}
async function runIndex(overrides: Record<string, any> = {}): Promise<void> {
mockedBuildParametersCreate.mockResolvedValue(createMockBuildParameters(overrides));
// index.ts exports `runMain` for testability (the file used to rely on
// top-level execution + jest's `vi.isolateModules`, but vitest 4 dropped
// that API). Calling the exported function directly is cleaner than
// round-tripping through dynamic imports.
const { runMain } = await import('./index');
await runMain();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('index.ts plugin lifecycle wiring', () => {
const originalPlatform = process.platform;
const originalEnvironment = { ...process.env };
beforeEach(() => {
vi.clearAllMocks();
process.env.GITHUB_WORKSPACE = '/workspace';
Object.defineProperty(process, 'platform', { value: 'linux' });
// Reset plugin to default behavior
mockPlugin.canHandleBuild.mockReturnValue(false);
mockPlugin.handleBuild.mockResolvedValue({ exitCode: 0 });
mockLoadPlugin.mockResolvedValue(mockPlugin);
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
process.env = { ...originalEnvironment };
});
// -----------------------------------------------------------------------
// Local build with plugin
// -----------------------------------------------------------------------
describe('local build with plugin installed', () => {
it('should call lifecycle hooks in order: initialize -> beforeLocalBuild -> [build] -> afterLocalBuild -> handlePostBuild', async () => {
const callOrder: string[] = [];
mockPlugin.initialize.mockImplementation(async () => callOrder.push('initialize'));
mockPlugin.beforeLocalBuild.mockImplementation(async () =>
callOrder.push('beforeLocalBuild'),
);
mockPlugin.afterLocalBuild.mockImplementation(async () => callOrder.push('afterLocalBuild'));
mockPlugin.handlePostBuild.mockImplementation(async () => callOrder.push('handlePostBuild'));
await runIndex();
expect(callOrder).toEqual([
'initialize',
'beforeLocalBuild',
'afterLocalBuild',
'handlePostBuild',
]);
});
it('should pass buildParameters and workspace to initialize', async () => {
await runIndex({ targetPlatform: 'WebGL' });
expect(mockPlugin.initialize).toHaveBeenCalledWith(
expect.objectContaining({ targetPlatform: 'WebGL' }),
'/workspace',
);
});
it('should pass workspace to beforeLocalBuild', async () => {
await runIndex();
expect(mockPlugin.beforeLocalBuild).toHaveBeenCalledWith('/workspace');
});
it('should pass workspace and exit code to afterLocalBuild', async () => {
await runIndex();
expect(mockPlugin.afterLocalBuild).toHaveBeenCalledWith('/workspace', 0);
});
it('should pass exit code to handlePostBuild', async () => {
await runIndex();
expect(mockPlugin.handlePostBuild).toHaveBeenCalledWith(0);
});
});
// -----------------------------------------------------------------------
// Plugin handles build entirely
// -----------------------------------------------------------------------
describe('plugin handles build (canHandleBuild = true)', () => {
it('should call handleBuild instead of Docker.run', async () => {
mockPlugin.canHandleBuild.mockReturnValue(true);
mockPlugin.handleBuild.mockResolvedValue({ exitCode: 0 });
await runIndex();
expect(mockPlugin.handleBuild).toHaveBeenCalledWith('mock-image:latest');
expect(Docker.run).not.toHaveBeenCalled();
expect(mockPlugin.beforeLocalBuild).not.toHaveBeenCalled();
expect(mockPlugin.afterLocalBuild).not.toHaveBeenCalled();
});
it('should still call handlePostBuild after handleBuild', async () => {
mockPlugin.canHandleBuild.mockReturnValue(true);
mockPlugin.handleBuild.mockResolvedValue({ exitCode: 0 });
await runIndex();
expect(mockPlugin.handlePostBuild).toHaveBeenCalledWith(0);
});
});
// -----------------------------------------------------------------------
// Fallback to local
// -----------------------------------------------------------------------
describe('fallback to local build', () => {
it('should do a local build when handleBuild returns fallbackToLocal', async () => {
mockPlugin.canHandleBuild.mockReturnValue(true);
mockPlugin.handleBuild.mockResolvedValue({ exitCode: -1, fallbackToLocal: true });
await runIndex();
expect(mockPlugin.handleBuild).toHaveBeenCalled();
expect(mockPlugin.beforeLocalBuild).toHaveBeenCalled();
expect(Docker.run).toHaveBeenCalled();
expect(mockPlugin.afterLocalBuild).toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// No plugin installed
// -----------------------------------------------------------------------
describe('no plugin installed', () => {
it('should build locally without errors when providerStrategy is local', async () => {
mockLoadPlugin.mockResolvedValue(undefined);
await runIndex({ providerStrategy: 'local' });
expect(Docker.run).toHaveBeenCalled();
});
it('should error when providerStrategy is non-local and no plugin', async () => {
mockLoadPlugin.mockResolvedValue(undefined);
await runIndex({ providerStrategy: 'aws' });
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('requires @game-ci/orchestrator'),
);
});
});
// -----------------------------------------------------------------------
// canHandleBuild = false with non-local provider
// -----------------------------------------------------------------------
describe('plugin installed but canHandleBuild returns false with non-local provider', () => {
it('should error when providerStrategy is non-local', async () => {
mockPlugin.canHandleBuild.mockReturnValue(false);
await runIndex({ providerStrategy: 'aws' });
// The plugin is initialized but says it can't handle the build,
// and providerStrategy is not local, so it falls to the error case
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('requires @game-ci/orchestrator'),
);
});
});
});

View File

@@ -1,13 +1,12 @@
import * as core from '@actions/core';
import { Action, BuildParameters, Cache, Docker, ImageTag, Output } from './model';
import path from 'node:path';
import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output } from './model';
import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup';
import { Plugin, loadPlugin } from './model/plugin';
import { BuildReliabilityService } from './model/orchestrator/services/reliability';
// Exported so tests can drive the lifecycle directly without depending on
// vitest's module re-loading (which changed in vitest 4).
export async function runMain() {
async function runMain() {
try {
if (Cli.InitCliMode()) {
await Cli.RunCli();
@@ -17,40 +16,179 @@ export async function runMain() {
Action.checkCompatibility();
Cache.verify();
// Always configure git environment for CI reliability
BuildReliabilityService.configureGitEnvironment();
const { workspace, actionFolder } = Action;
const buildParameters = await BuildParameters.create();
const baseImage = new ImageTag(buildParameters);
// Load optional plugin. The default implementation is @game-ci/orchestrator.
const plugin = await loadPlugin();
await plugin?.initialize(buildParameters, workspace);
// 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;
if (plugin?.canHandleBuild()) {
// Plugin handles the build entirely (remote providers, hot runner, test workflows)
const result = await plugin.handleBuild(baseImage.toString());
if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
exitCode = result.fallbackToLocal
? await runLocalBuild(buildParameters, baseImage, workspace, actionFolder, plugin)
: result.exitCode;
} else if (buildParameters.providerStrategy === 'local') {
exitCode = await runLocalBuild(buildParameters, baseImage, workspace, actionFolder, plugin);
// Child workspace isolation - restore cached workspace before any other setup
let childWorkspaceConfig: any;
if (buildParameters.childWorkspacesEnabled && buildParameters.childWorkspaceName) {
const { ChildWorkspaceService } = await import('./model/orchestrator/services/cache/child-workspace-service');
const cacheRoot =
buildParameters.childWorkspaceCacheRoot ||
path.join(buildParameters.runnerTempPath || process.env.RUNNER_TEMP || '', 'game-ci-workspaces');
childWorkspaceConfig = ChildWorkspaceService.buildConfig({
childWorkspacesEnabled: buildParameters.childWorkspacesEnabled,
childWorkspaceName: buildParameters.childWorkspaceName,
childWorkspaceCacheRoot: cacheRoot,
childWorkspacePreserveGit: buildParameters.childWorkspacePreserveGit,
childWorkspaceSeparateLibrary: buildParameters.childWorkspaceSeparateLibrary,
});
const projectFullPath = path.join(workspace, buildParameters.projectPath);
const restored = ChildWorkspaceService.initializeWorkspace(projectFullPath, childWorkspaceConfig);
core.info(
`Child workspace "${buildParameters.childWorkspaceName}": ${
restored ? 'restored from cache' : 'starting fresh'
}`,
);
// Log workspace size for resource tracking
const size = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
core.info(`Child workspace size after restore: ${size}`);
}
// Submodule profile initialization
if (buildParameters.submoduleProfilePath) {
const { SubmoduleProfileService } = await import(
'./model/orchestrator/services/submodule/submodule-profile-service'
);
core.info('Initializing submodules from profile...');
const plan = await SubmoduleProfileService.createInitPlan(
buildParameters.submoduleProfilePath,
buildParameters.submoduleVariantPath,
workspace,
);
await SubmoduleProfileService.execute(
plan,
workspace,
buildParameters.submoduleToken || buildParameters.gitPrivateToken,
);
}
// Configure custom LFS transfer agent
if (buildParameters.lfsTransferAgent) {
const { LfsAgentService } = await import('./model/orchestrator/services/lfs/lfs-agent-service');
core.info('Configuring custom LFS transfer agent...');
await LfsAgentService.configure(
buildParameters.lfsTransferAgent,
buildParameters.lfsTransferAgentArgs,
buildParameters.lfsStoragePaths ? buildParameters.lfsStoragePaths.split(';') : [],
workspace,
);
}
// Local build caching - restore
let cacheRoot = '';
let cacheKey = '';
if (buildParameters.localCacheEnabled) {
const { LocalCacheService } = await import('./model/orchestrator/services/cache/local-cache-service');
cacheRoot = LocalCacheService.resolveCacheRoot(buildParameters);
cacheKey = LocalCacheService.generateCacheKey(
buildParameters.targetPlatform,
buildParameters.editorVersion,
buildParameters.branch || '',
);
if (buildParameters.localCacheLfs) {
await LocalCacheService.restoreLfsCache(workspace, cacheRoot, cacheKey);
}
if (buildParameters.localCacheLibrary) {
const projectFullPath = path.join(workspace, buildParameters.projectPath);
await LocalCacheService.restoreLibraryCache(projectFullPath, cacheRoot, cacheKey);
}
}
// Git hooks — opt-in only. When disabled (default), do not touch hooks at all.
if (buildParameters.gitHooksEnabled) {
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
await GitHooksService.installHooks(workspace);
if (buildParameters.gitHooksSkipList) {
const environment = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
Object.assign(process.env, environment);
}
}
await PlatformSetup.setup(buildParameters, actionFolder);
exitCode =
process.platform === 'darwin'
? await MacBuilder.run(actionFolder)
: await Docker.run(baseImage.toString(), {
workspace,
actionFolder,
...buildParameters,
});
// Local build caching - save
if (buildParameters.localCacheEnabled) {
const { LocalCacheService } = await import('./model/orchestrator/services/cache/local-cache-service');
if (buildParameters.localCacheLibrary) {
const projectFullPath = path.join(workspace, buildParameters.projectPath);
await LocalCacheService.saveLibraryCache(projectFullPath, cacheRoot, cacheKey);
}
if (buildParameters.localCacheLfs) {
await LocalCacheService.saveLfsCache(workspace, cacheRoot, cacheKey);
}
}
// Child workspace isolation - save workspace for next run
if (childWorkspaceConfig && childWorkspaceConfig.enabled) {
const { ChildWorkspaceService } = await import('./model/orchestrator/services/cache/child-workspace-service');
const projectFullPath = path.join(workspace, buildParameters.projectPath);
const preSaveSize = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
core.info(`Child workspace size before save: ${preSaveSize}`);
ChildWorkspaceService.saveWorkspace(projectFullPath, childWorkspaceConfig);
core.info(`Child workspace "${buildParameters.childWorkspaceName}" saved to cache`);
}
} else {
throw new Error(
`Provider strategy "${buildParameters.providerStrategy}" requires @game-ci/orchestrator. ` +
'Install it via the game-ci/orchestrator action, or use providerStrategy=local.',
);
await Orchestrator.run(buildParameters, baseImage.toString());
exitCode = 0;
}
// Set core outputs
// Post-build: archive and enforce retention
if (buildParameters.buildArchiveEnabled && exitCode === 0) {
core.info('Archiving build output...');
BuildReliabilityService.archiveBuildOutput(buildParameters.buildPath, buildParameters.buildArchivePath);
BuildReliabilityService.enforceRetention(buildParameters.buildArchivePath, buildParameters.buildArchiveRetention);
}
// Set output
await Output.setBuildVersion(buildParameters.buildVersion);
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
await Output.setEngineExitCode(exitCode);
// Plugin handles post-build (artifacts, archiving, retention)
await plugin?.handlePostBuild(exitCode);
if (exitCode !== 0) {
core.setFailed(`Build failed with exit code ${exitCode}`);
}
@@ -59,33 +197,4 @@ export async function runMain() {
}
}
async function runLocalBuild(
buildParameters: BuildParameters,
baseImage: ImageTag,
workspace: string,
actionFolder: string,
plugin?: Plugin,
): Promise<number> {
await plugin?.beforeLocalBuild(workspace);
await PlatformSetup.setup(buildParameters, actionFolder);
const exitCode =
process.platform === 'darwin'
? await MacBuilder.run(actionFolder)
: await Docker.run(baseImage.toString(), {
workspace,
actionFolder,
...buildParameters,
});
await plugin?.afterLocalBuild(workspace, exitCode);
return exitCode;
}
// Auto-run when this module is the entry point. Tests import the file via
// `await import('./index')` purely to register the mock factories and then
// call `runMain()` directly.
if (process.env.NODE_ENV !== 'test') {
runMain();
}
runMain();

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest';
import Versioning from './versioning';
import UnityVersioning from './unity-versioning';
import AndroidVersioning from './android-versioning';
@@ -10,12 +9,12 @@ const testLicense =
'<?xml version="1.0" encoding="UTF-8"?><root>\n <License id="Terms">\n <MachineBindings>\n <Binding Key="1" Value="576562626572264761624c65526f7578"/>\n <Binding Key="2" Value="576562626572264761624c65526f7578"/>\n </MachineBindings>\n <MachineID Value="D7nTUnjNAmtsUMcnoyrqkgIbYdM="/>\n <SerialHash Value="2033b8ac3e6faa3742ca9f0bfae44d18f2a96b80"/>\n <Features>\n <Feature Value="33"/>\n <Feature Value="1"/>\n <Feature Value="12"/>\n <Feature Value="2"/>\n <Feature Value="24"/>\n <Feature Value="3"/>\n <Feature Value="36"/>\n <Feature Value="17"/>\n <Feature Value="19"/>\n <Feature Value="62"/>\n </Features>\n <DeveloperData Value="AQAAAEY0LUJHUlgtWEQ0RS1aQ1dWLUM1SlctR0RIQg=="/>\n <SerialMasked Value="F4-BGRX-XD4E-ZCWV-C5JW-XXXX"/>\n <StartDate Value="2021-02-08T00:00:00"/>\n <UpdateDate Value="2021-02-09T00:34:57"/>\n <InitialActivationDate Value="2021-02-08T00:34:56"/>\n <LicenseVersion Value="6.x"/>\n <ClientProvidedVersion Value="2018.4.30f1"/>\n <AlwaysOnline Value="false"/>\n <Entitlements>\n <Entitlement Ns="unity_editor" Tag="UnityPersonal" Type="EDITOR" ValidTo="9999-12-31T00:00:00"/>\n <Entitlement Ns="unity_editor" Tag="DarkSkin" Type="EDITOR_FEATURE" ValidTo="9999-12-31T00:00:00"/>\n </Entitlements>\n </License>\n<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#Terms"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>m0Db8UK+ktnOLJBtHybkfetpcKo=</DigestValue></Reference></SignedInfo><SignatureValue>o/pUbSQAukz7+ZYAWhnA0AJbIlyyCPL7bKVEM2lVqbrXt7cyey+umkCXamuOgsWPVUKBMkXtMH8L\n5etLmD0getWIhTGhzOnDCk+gtIPfL4jMo9tkEuOCROQAXCci23VFscKcrkB+3X6h4wEOtA2APhOY\nB+wvC794o8/82ffjP79aVAi57rp3Wmzx+9pe9yMwoJuljAy2sc2tIMgdQGWVmOGBpQm3JqsidyzI\nJWG2kjnc7pDXK9pwYzXoKiqUqqrut90d+kQqRyv7MSZXR50HFqD/LI69h68b7P8Bjo3bPXOhNXGR\n9YCoemH6EkfCJxp2gIjzjWW+l2Hj2EsFQi8YXw==</SignatureValue></Signature></root>';
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
jest.clearAllMocks();
jest.restoreAllMocks();
});
beforeEach(() => {
vi.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37');
jest.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37');
process.env.UNITY_LICENSE = testLicense; // Todo - Don't use process.env directly, that's what the input model class is for.
});
@@ -26,20 +25,20 @@ describe('BuildParameters', () => {
});
it('determines the version only once', async () => {
vi.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37');
jest.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37');
await BuildParameters.create();
await expect(Versioning.determineBuildVersion).toHaveBeenCalledTimes(1);
});
it('determines the unity version only once', async () => {
vi.spyOn(UnityVersioning, 'determineUnityVersion').mockImplementation(() => '2019.2.11f1');
jest.spyOn(UnityVersioning, 'determineUnityVersion').mockImplementation(() => '2019.2.11f1');
await BuildParameters.create();
expect(UnityVersioning.determineUnityVersion).toHaveBeenCalledTimes(1);
});
it('returns the android version code with provided input', async () => {
const mockValue = '42';
vi.spyOn(Input, 'androidVersionCode', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidVersionCode', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidVersionCode: mockValue }),
);
@@ -47,59 +46,49 @@ describe('BuildParameters', () => {
it('returns the android version code from version by default', async () => {
const mockValue = '';
vi.spyOn(Input, 'androidVersionCode', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidVersionCode', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidVersionCode: '1003037' }),
);
});
it('determines the android sdk manager parameters only once', async () => {
vi.spyOn(AndroidVersioning, 'determineSdkManagerParameters').mockImplementation(
() => 'platforms;android-30',
);
jest.spyOn(AndroidVersioning, 'determineSdkManagerParameters').mockImplementation(() => 'platforms;android-30');
await BuildParameters.create();
expect(AndroidVersioning.determineSdkManagerParameters).toHaveBeenCalledTimes(1);
});
it('returns the targetPlatform', async () => {
const mockValue = 'somePlatform';
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ targetPlatform: mockValue }),
);
jest.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ targetPlatform: mockValue }));
});
it('returns the project path', async () => {
const mockValue = 'path/to/project';
vi.spyOn(UnityVersioning, 'determineUnityVersion').mockImplementation(() => '2019.2.11f1');
vi.spyOn(Input, 'projectPath', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ projectPath: mockValue }),
);
jest.spyOn(UnityVersioning, 'determineUnityVersion').mockImplementation(() => '2019.2.11f1');
jest.spyOn(Input, 'projectPath', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ projectPath: mockValue }));
});
it('returns the build profile', async () => {
const mockValue = 'path/to/build_profile.asset';
vi.spyOn(Input, 'buildProfile', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ buildProfile: mockValue }),
);
jest.spyOn(Input, 'buildProfile', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ buildProfile: mockValue }));
});
it('returns the build name', async () => {
const mockValue = 'someBuildName';
vi.spyOn(Input, 'buildName', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ buildName: mockValue }),
);
jest.spyOn(Input, 'buildName', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ buildName: mockValue }));
});
it('returns the build path', async () => {
const mockPath = 'somePath';
const mockPlatform = 'somePlatform';
const expectedBuildPath = `${mockPath}/${mockPlatform}`;
vi.spyOn(Input, 'buildsPath', 'get').mockReturnValue(mockPath);
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockPlatform);
jest.spyOn(Input, 'buildsPath', 'get').mockReturnValue(mockPath);
jest.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockPlatform);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ buildPath: expectedBuildPath }),
);
@@ -109,11 +98,9 @@ describe('BuildParameters', () => {
const mockValue = 'someBuildName';
const mockPlatform = 'somePlatform';
vi.spyOn(Input, 'buildName', 'get').mockReturnValue(mockValue);
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockPlatform);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ buildFile: mockValue }),
);
jest.spyOn(Input, 'buildName', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockPlatform);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ buildFile: mockValue }));
});
test.each`
@@ -126,9 +113,9 @@ describe('BuildParameters', () => {
`(
'appends $expectedExtension for $targetPlatform with androidExportType $androidExportType',
async ({ targetPlatform, expectedExtension, androidExportType }) => {
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(targetPlatform);
vi.spyOn(Input, 'buildName', 'get').mockReturnValue(targetPlatform);
vi.spyOn(Input, 'androidExportType', 'get').mockReturnValue(androidExportType);
jest.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(targetPlatform);
jest.spyOn(Input, 'buildName', 'get').mockReturnValue(targetPlatform);
jest.spyOn(Input, 'androidExportType', 'get').mockReturnValue(androidExportType);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ buildFile: `${targetPlatform}${expectedExtension}` }),
);
@@ -145,26 +132,22 @@ describe('BuildParameters', () => {
`(
'androidSymbolType is set to $androidSymbolType when targetPlatform is $targetPlatform and input targetSymbolType is $androidSymbolType',
async ({ targetPlatform, androidSymbolType }) => {
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(targetPlatform);
vi.spyOn(Input, 'androidSymbolType', 'get').mockReturnValue(androidSymbolType);
vi.spyOn(Input, 'buildName', 'get').mockReturnValue(targetPlatform);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidSymbolType }),
);
jest.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(targetPlatform);
jest.spyOn(Input, 'androidSymbolType', 'get').mockReturnValue(androidSymbolType);
jest.spyOn(Input, 'buildName', 'get').mockReturnValue(targetPlatform);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ androidSymbolType }));
},
);
it('returns the build method', async () => {
const mockValue = 'Namespace.ClassName.BuildMethod';
vi.spyOn(Input, 'buildMethod', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ buildMethod: mockValue }),
);
jest.spyOn(Input, 'buildMethod', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ buildMethod: mockValue }));
});
it('returns the android keystore name', async () => {
const mockValue = 'keystore.keystore';
vi.spyOn(Input, 'androidKeystoreName', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidKeystoreName', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidKeystoreName: mockValue }),
);
@@ -172,7 +155,7 @@ describe('BuildParameters', () => {
it('returns the android keystore base64-encoded content', async () => {
const mockValue = 'secret';
vi.spyOn(Input, 'androidKeystoreBase64', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidKeystoreBase64', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidKeystoreBase64: mockValue }),
);
@@ -180,7 +163,7 @@ describe('BuildParameters', () => {
it('returns the android keystore pass', async () => {
const mockValue = 'secret';
vi.spyOn(Input, 'androidKeystorePass', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidKeystorePass', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidKeystorePass: mockValue }),
);
@@ -188,7 +171,7 @@ describe('BuildParameters', () => {
it('returns the android keyalias name', async () => {
const mockValue = 'secret';
vi.spyOn(Input, 'androidKeyaliasName', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidKeyaliasName', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidKeyaliasName: mockValue }),
);
@@ -196,7 +179,7 @@ describe('BuildParameters', () => {
it('returns the android keyalias pass', async () => {
const mockValue = 'secret';
vi.spyOn(Input, 'androidKeyaliasPass', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidKeyaliasPass', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidKeyaliasPass: mockValue }),
);
@@ -204,7 +187,7 @@ describe('BuildParameters', () => {
it('returns the android target sdk version', async () => {
const mockValue = 'AndroidApiLevelAuto';
vi.spyOn(Input, 'androidTargetSdkVersion', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'androidTargetSdkVersion', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ androidTargetSdkVersion: mockValue }),
);
@@ -212,7 +195,7 @@ describe('BuildParameters', () => {
it('returns the unity licensing server address', async () => {
const mockValue = 'http://example.com';
vi.spyOn(Input, 'unityLicensingServer', 'get').mockReturnValue(mockValue);
jest.spyOn(Input, 'unityLicensingServer', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ unityLicensingServer: mockValue }),
);
@@ -227,25 +210,14 @@ describe('BuildParameters', () => {
const mockValue = '123';
delete process.env.UNITY_LICENSE; // Need to delete this as it is set for every test currently
process.env.UNITY_SERIAL = mockValue;
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ unitySerial: mockValue }),
);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ unitySerial: mockValue }));
delete process.env.UNITY_SERIAL;
});
it('returns the custom parameters', async () => {
const mockValue = '-profile SomeProfile -someBoolean -someValue exampleValue';
vi.spyOn(Input, 'customParameters', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ customParameters: mockValue }),
);
});
it.each([true, false])('returns the flag for useHostNetwork when %s', async (mockValue) => {
vi.spyOn(Input, 'useHostNetwork', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(
expect.objectContaining({ useHostNetwork: mockValue }),
);
jest.spyOn(Input, 'customParameters', 'get').mockReturnValue(mockValue);
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ customParameters: mockValue }));
});
});
});

View File

@@ -1,5 +1,7 @@
import { customAlphabet } from 'nanoid';
import AndroidVersioning from './android-versioning';
import OrchestratorConstants from './orchestrator/options/orchestrator-constants';
import OrchestratorBuildGuid from './orchestrator/options/orchestrator-guid';
import Input from './input';
import Platform from './platform';
import UnityVersioning from './unity-versioning';
@@ -8,6 +10,8 @@ import { GitRepoReader } from './input-readers/git-repo';
import { GithubCliReader } from './input-readers/github-cli';
import { Cli } from './cli/cli';
import GitHub from './github';
import OrchestratorOptions from './orchestrator/options/orchestrator-options';
import Orchestrator from './orchestrator/orchestrator';
import * as core from '@actions/core';
class BuildParameters {
@@ -47,48 +51,152 @@ class BuildParameters {
public containerRegistryImageVersion!: string;
public customParameters!: string;
public useHostNetwork!: boolean;
public sshAgent!: string;
public sshPublicKeysDirectoryPath!: string;
public providerStrategy!: string;
public gitAuthMode!: string;
public fallbackProviderStrategy!: string;
public runnerCheckEnabled!: boolean;
public runnerCheckLabels!: string[];
public runnerCheckMinAvailable!: number;
public retryOnFallback!: boolean;
public providerInitTimeout!: number;
public gitPrivateToken!: string;
public awsStackName!: string;
public awsEndpoint?: string;
public awsCloudFormationEndpoint?: string;
public awsEcsEndpoint?: string;
public awsKinesisEndpoint?: string;
public awsCloudWatchLogsEndpoint?: string;
public awsS3Endpoint?: string;
public storageProvider!: string;
public rcloneRemote!: string;
public kubeConfig!: string;
public containerMemory!: string;
public containerCpu!: string;
public containerNamespace!: string;
public kubeVolumeSize!: string;
public kubeVolume!: string;
public kubeStorageClass!: string;
public runAsHostUser!: string;
public chownFilesTo!: string;
public commandHooks!: string;
public pullInputList!: string[];
public inputPullCommand!: string;
public cacheKey!: string;
public postBuildContainerHooks!: string;
public preBuildContainerHooks!: string;
public customJob!: string;
public runNumber!: string;
public branch!: string;
public githubRepo!: string;
public orchestratorRepoName!: string;
public cloneDepth!: number;
public gitSha!: string;
public logId!: string;
public buildGuid!: string;
public orchestratorBranch!: string;
public orchestratorDebug!: boolean | undefined;
public buildPlatform!: string | undefined;
public isCliMode!: boolean;
public maxRetainedWorkspaces!: number;
public useLargePackages!: boolean;
public useCompressionStrategy!: boolean;
public garbageMaxAge!: number;
public githubChecks!: boolean;
public asyncWorkflow!: boolean;
public githubCheckId!: string;
public finalHooks!: string[];
public skipLfs!: boolean;
public skipCache!: boolean;
public cacheUnityInstallationOnMac!: boolean;
public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: string;
public submoduleProfilePath!: string;
public submoduleVariantPath!: string;
public submoduleToken!: string;
public localCacheEnabled!: boolean;
public localCacheRoot!: string;
public localCacheLibrary!: boolean;
public localCacheLfs!: boolean;
public childWorkspacesEnabled!: boolean;
public childWorkspaceName!: string;
public childWorkspaceCacheRoot!: string;
public childWorkspacePreserveGit!: boolean;
public childWorkspaceSeparateLibrary!: boolean;
public lfsTransferAgent!: string;
public lfsTransferAgentArgs!: string;
public lfsStoragePaths!: string;
public gitHooksEnabled!: boolean;
public gitHooksSkipList!: string;
public gitHooksRunBeforeBuild!: string;
public providerExecutable!: string;
public gitIntegrityCheck!: boolean;
public gitAutoRecover!: boolean;
public cleanReservedFilenames!: boolean;
public buildArchiveEnabled!: boolean;
public buildArchivePath!: string;
public buildArchiveRetention!: number;
// 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 static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
}
static async create(): Promise<BuildParameters> {
const buildFile = this.parseBuildFile(
Input.buildName,
Input.targetPlatform,
Input.androidExportType,
);
const editorVersion = UnityVersioning.determineUnityVersion(
Input.projectPath,
Input.unityVersion,
);
const buildVersion = await Versioning.determineBuildVersion(
Input.versioningStrategy,
Input.specifiedVersion,
);
const androidVersionCode = AndroidVersioning.determineVersionCode(
buildVersion,
Input.androidVersionCode,
);
const androidSdkManagerParameters = AndroidVersioning.determineSdkManagerParameters(
Input.androidTargetSdkVersion,
);
const buildFile = this.parseBuildFile(Input.buildName, Input.targetPlatform, Input.androidExportType);
const editorVersion = UnityVersioning.determineUnityVersion(Input.projectPath, Input.unityVersion);
const buildVersion = await Versioning.determineBuildVersion(Input.versioningStrategy, Input.specifiedVersion);
const androidVersionCode = AndroidVersioning.determineVersionCode(buildVersion, Input.androidVersionCode);
const androidSdkManagerParameters = AndroidVersioning.determineSdkManagerParameters(Input.androidTargetSdkVersion);
const androidSymbolExportType = Input.androidSymbolType;
if (Platform.isAndroid(Input.targetPlatform)) {
@@ -127,9 +235,6 @@ class BuildParameters {
core.setSecret(`${unitySerial.slice(0, -4)}XXXX`);
}
const providerStrategy =
Input.getInput('providerStrategy') || (Cli.isCliMode ? 'aws' : 'local');
return {
editorVersion,
customImage: Input.customImage,
@@ -158,7 +263,6 @@ class BuildParameters {
androidExportType: Input.androidExportType,
androidSymbolType: androidSymbolExportType,
customParameters: Input.customParameters,
useHostNetwork: Input.useHostNetwork,
sshAgent: Input.sshAgent,
sshPublicKeysDirectoryPath: Input.sshPublicKeysDirectoryPath,
gitPrivateToken: Input.gitPrivateToken ?? (await GithubCliReader.GetGitHubAuthToken()),
@@ -169,22 +273,131 @@ class BuildParameters {
dockerIsolationMode: Input.dockerIsolationMode,
containerRegistryRepository: Input.containerRegistryRepository,
containerRegistryImageVersion: Input.containerRegistryImageVersion,
providerStrategy,
buildPlatform: providerStrategy !== 'local' ? 'linux' : process.platform,
providerStrategy: OrchestratorOptions.providerStrategy,
gitAuthMode: OrchestratorOptions.gitAuthMode,
fallbackProviderStrategy: OrchestratorOptions.fallbackProviderStrategy,
runnerCheckEnabled: OrchestratorOptions.runnerCheckEnabled,
runnerCheckLabels: OrchestratorOptions.runnerCheckLabels,
runnerCheckMinAvailable: OrchestratorOptions.runnerCheckMinAvailable,
retryOnFallback: OrchestratorOptions.retryOnFallback,
providerInitTimeout: OrchestratorOptions.providerInitTimeout,
buildPlatform: OrchestratorOptions.buildPlatform,
kubeConfig: OrchestratorOptions.kubeConfig,
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,
branch: Input.branch.replace('/head', '') || (await GitRepoReader.GetBranch()),
githubRepo:
(Input.githubRepo ?? (await GitRepoReader.GetRemote())) || 'game-ci/unity-builder',
gitSha: Input.gitSha,
logId: customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 9)(),
buildGuid: `${Input.runNumber}-${Input.targetPlatform.toLowerCase().replace('standalone', '')}-${customAlphabet(
'0123456789abcdefghijklmnopqrstuvwxyz',
4,
)()}`,
orchestratorBranch: OrchestratorOptions.orchestratorBranch.split('/').reverse()[0],
orchestratorDebug: OrchestratorOptions.orchestratorDebug,
githubRepo: (Input.githubRepo ?? (await GitRepoReader.GetRemote())) || OrchestratorOptions.orchestratorRepoName,
orchestratorRepoName: OrchestratorOptions.orchestratorRepoName,
cloneDepth: Number.parseInt(OrchestratorOptions.cloneDepth),
isCliMode: Cli.isCliMode,
awsStackName: OrchestratorOptions.awsStackName,
awsEndpoint: OrchestratorOptions.awsEndpoint,
awsCloudFormationEndpoint: OrchestratorOptions.awsCloudFormationEndpoint,
awsEcsEndpoint: OrchestratorOptions.awsEcsEndpoint,
awsKinesisEndpoint: OrchestratorOptions.awsKinesisEndpoint,
awsCloudWatchLogsEndpoint: OrchestratorOptions.awsCloudWatchLogsEndpoint,
awsS3Endpoint: OrchestratorOptions.awsS3Endpoint,
storageProvider: OrchestratorOptions.storageProvider,
rcloneRemote: OrchestratorOptions.rcloneRemote,
gitSha: Input.gitSha,
logId: customAlphabet(OrchestratorConstants.alphabet, 9)(),
buildGuid: OrchestratorBuildGuid.generateGuid(Input.runNumber, Input.targetPlatform),
commandHooks: OrchestratorOptions.commandHooks,
inputPullCommand: OrchestratorOptions.inputPullCommand,
pullInputList: OrchestratorOptions.pullInputList,
kubeStorageClass: OrchestratorOptions.kubeStorageClass,
gcpProject: Input.gcpProject,
gcpRegion: Input.gcpRegion,
gcpStorageType: Input.gcpStorageType,
gcpBucket: Input.gcpBucket,
gcpFilestoreIp: Input.gcpFilestoreIp,
gcpFilestoreShare: Input.gcpFilestoreShare,
gcpMachineType: Input.gcpMachineType,
gcpDiskSizeGb: Input.gcpDiskSizeGb,
gcpServiceAccount: Input.gcpServiceAccount,
gcpVpcConnector: Input.gcpVpcConnector,
azureResourceGroup: Input.azureResourceGroup,
azureLocation: Input.azureLocation,
azureStorageType: Input.azureStorageType,
azureStorageAccount: Input.azureStorageAccount,
azureBlobContainer: Input.azureBlobContainer,
azureFileShareName: Input.azureFileShareName,
azureSubscriptionId: Input.azureSubscriptionId,
azureCpu: Input.azureCpu,
azureMemoryGb: Input.azureMemoryGb,
azureDiskSizeGb: Input.azureDiskSizeGb,
azureSubnetId: Input.azureSubnetId,
cacheKey: OrchestratorOptions.cacheKey,
maxRetainedWorkspaces: Number.parseInt(OrchestratorOptions.maxRetainedWorkspaces),
useLargePackages: OrchestratorOptions.useLargePackages,
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,
unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath,
submoduleProfilePath: Input.submoduleProfilePath,
submoduleVariantPath: Input.submoduleVariantPath,
submoduleToken: Input.submoduleToken,
localCacheEnabled: Input.localCacheEnabled,
localCacheRoot: Input.localCacheRoot,
localCacheLibrary: Input.localCacheLibrary,
localCacheLfs: Input.localCacheLfs,
childWorkspacesEnabled: Input.childWorkspacesEnabled,
childWorkspaceName: Input.childWorkspaceName,
childWorkspaceCacheRoot: Input.childWorkspaceCacheRoot,
childWorkspacePreserveGit: Input.childWorkspacePreserveGit,
childWorkspaceSeparateLibrary: Input.childWorkspaceSeparateLibrary,
lfsTransferAgent: Input.lfsTransferAgent,
lfsTransferAgentArgs: Input.lfsTransferAgentArgs,
lfsStoragePaths: Input.lfsStoragePaths,
gitHooksEnabled: Input.gitHooksEnabled,
gitHooksSkipList: Input.gitHooksSkipList,
gitHooksRunBeforeBuild: Input.gitHooksRunBeforeBuild,
providerExecutable: Input.providerExecutable,
gitIntegrityCheck: Input.gitIntegrityCheck,
gitAutoRecover: Input.gitAutoRecover,
cleanReservedFilenames: Input.cleanReservedFilenames,
buildArchiveEnabled: Input.buildArchiveEnabled,
buildArchivePath: Input.buildArchivePath,
buildArchiveRetention: Input.buildArchiveRetention,
// 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,
};
}

View File

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

View File

@@ -1,10 +1,19 @@
import { Command } from 'commander-ts';
import { Input } from '..';
import { BuildParameters, Orchestrator, ImageTag, Input } from '..';
import * as core from '@actions/core';
import { ActionYamlReader } from '../input-readers/action-yaml';
import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger';
import OrchestratorQueryOverride from '../orchestrator/options/orchestrator-query-override';
import { CliFunction, CliFunctionsRepository } from './cli-functions-repository';
import { Caching } from '../orchestrator/remote-client/caching';
import { LfsHashing } from '../orchestrator/services/utility/lfs-hashing';
import { RemoteClient } from '../orchestrator/remote-client';
import OrchestratorOptionsReader from '../orchestrator/options/orchestrator-options-reader';
import GitHub from '../github';
import { OptionValues } from 'commander';
import { InputKey } from '../input';
import { SubmoduleProfileService } from '../orchestrator/services/submodule/submodule-profile-service';
import { LfsAgentService } from '../orchestrator/services/lfs/lfs-agent-service';
export class Cli {
public static options: OptionValues | undefined;
@@ -23,13 +32,14 @@ export class Cli {
}
public static InitCliMode() {
CliFunctionsRepository.PushCliFunctionSource(RemoteClient);
CliFunctionsRepository.PushCliFunctionSource(Caching);
CliFunctionsRepository.PushCliFunctionSource(LfsHashing);
const program = new Command();
program.version('0.0.1');
const properties = OrchestratorOptionsReader.GetProperties();
const actionYamlReader: ActionYamlReader = new ActionYamlReader();
const properties = Object.getOwnPropertyNames(Input).filter(
(p) => p !== 'length' && p !== 'prototype' && p !== 'name',
);
for (const element of properties) {
program.option(`--${element} <${element}>`, actionYamlReader.GetActionYamlValue(element));
}
@@ -39,10 +49,7 @@ export class Cli {
.map((x) => `${x.key} (${x.description})`)
.join(` | `),
);
program.option(
'--populateOverride <populateOverride>',
'should use override query to pull input false by default',
);
program.option('--populateOverride <populateOverride>', 'should use override query to pull input false by default');
program.option('--cachePushFrom <cachePushFrom>', 'cache push from source folder');
program.option('--cachePushTo <cachePushTo>', 'cache push to caching folder');
program.option('--artifactName <artifactName>', 'caching artifact name');
@@ -52,10 +59,7 @@ export class Cli {
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.option('--storagePaths <storagePaths>', 'semicolon-separated storage paths for LFS agent');
program.parse(process.argv);
Cli.options = program.opts();
@@ -63,15 +67,26 @@ export class Cli {
}
static async RunCli(): Promise<void> {
const results = CliFunctionsRepository.GetCliFunctions(Cli.options?.mode);
if (!results) {
throw new Error(
`Unknown CLI mode: ${Cli.options?.mode}. Orchestrator CLI features require @game-ci/orchestrator.`,
);
GitHub.githubInputEnabled = false;
if (Cli.options!['populateOverride'] === `true`) {
await OrchestratorQueryOverride.PopulateQueryOverrideInput();
}
core.info(`Entrypoint: ${results.key}`);
if (Cli.options!['logInput']) {
Cli.logInput();
}
const results = CliFunctionsRepository.GetCliFunctions(Cli.options?.mode);
OrchestratorLogger.log(`Entrypoint: ${results.key}`);
Cli.options!.versioning = 'None';
Orchestrator.buildParameters = await BuildParameters.create();
Orchestrator.buildParameters.buildGuid = process.env.BUILD_GUID || ``;
OrchestratorLogger.log(`Build Params:
${JSON.stringify(Orchestrator.buildParameters, undefined, 4)}
`);
Orchestrator.lockedWorkspace = process.env.LOCKED_WORKSPACE || ``;
OrchestratorLogger.log(`Locked Workspace: ${Orchestrator.lockedWorkspace}`);
await Orchestrator.setup(Orchestrator.buildParameters);
return await results.target[results.propertyKey](Cli.options);
}
@@ -79,9 +94,7 @@ export class Cli {
private static logInput() {
core.info(`\n`);
core.info(`INPUT:`);
const properties = Object.getOwnPropertyNames(Input).filter(
(p) => p !== 'length' && p !== 'prototype' && p !== 'name',
);
const properties = OrchestratorOptionsReader.GetProperties();
for (const element of properties) {
if (
element in Input &&
@@ -97,4 +110,95 @@ export class Cli {
}
core.info(`\n`);
}
@CliFunction(`cli-build`, `runs a orchestrator build`)
public static async CLIBuild(): Promise<string> {
const buildParameter = await BuildParameters.create();
const baseImage = new ImageTag(buildParameter);
return (await Orchestrator.run(buildParameter, baseImage.toString())).BuildResults;
}
@CliFunction(`async-workflow`, `runs a orchestrator build`)
public static async asyncronousWorkflow(): Promise<string> {
const buildParameter = await BuildParameters.create();
const baseImage = new ImageTag(buildParameter);
await Orchestrator.setup(buildParameter);
return (await Orchestrator.run(buildParameter, baseImage.toString())).BuildResults;
}
@CliFunction(`checks-update`, `runs a orchestrator build`)
public static async checksUpdate() {
const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter);
const input = JSON.parse(process.env.CHECKS_UPDATE || ``);
core.info(`Checks Update ${process.env.CHECKS_UPDATE}`);
if (input.mode === `create`) {
throw new Error(`Not supported: only use update`);
} else if (input.mode === `update`) {
await GitHub.updateGitHubCheckRequest(input.data);
}
}
@CliFunction(`garbage-collect`, `runs garbage collection`)
public static async GarbageCollect(): Promise<string> {
const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter);
return await Orchestrator.Provider.garbageCollect(``, false, 0, false, false);
}
@CliFunction(`list-resources`, `lists active resources`)
public static async ListResources(): Promise<string[]> {
const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter);
const result = await Orchestrator.Provider.listResources();
OrchestratorLogger.log(JSON.stringify(result, undefined, 4));
return result.map((x) => x.Name);
}
@CliFunction(`list-worfklow`, `lists running workflows`)
public static async ListWorfklow(): Promise<string[]> {
const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter);
return (await Orchestrator.Provider.listWorkflow()).map((x) => x.Name);
}
@CliFunction(`watch`, `follows logs of a running workflow`)
public static async Watch(): Promise<string> {
const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter);
return await Orchestrator.Provider.watchWorkflow();
}
@CliFunction(`submodule-init`, `initializes submodules from a YAML profile`)
public static async SubmoduleInit(): Promise<void> {
const profilePath = Cli.options!['profilePath'];
const variantPath = Cli.options!['variantPath'] || '';
if (!profilePath) {
throw new Error('--profilePath is required for submodule-init');
}
const plan = await SubmoduleProfileService.createInitPlan(profilePath, variantPath, process.cwd());
await SubmoduleProfileService.execute(plan, process.cwd());
}
@CliFunction(`lfs-agent-configure`, `configures a custom LFS transfer agent`)
public static async LfsAgentConfigure(): Promise<void> {
const agentPath = Cli.options!['agentPath'];
if (!agentPath) {
throw new Error('--agentPath is required for lfs-agent-configure');
}
const agentArgs = Cli.options!['agentArgs'] || '';
const storagePaths = (Cli.options!['storagePaths'] || '').split(';').filter(Boolean);
await LfsAgentService.configure(agentPath, agentArgs, storagePaths, process.cwd());
}
}

View File

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

View File

@@ -17,13 +17,7 @@ class Docker {
let runCommand = '';
switch (process.platform) {
case 'linux':
runCommand = this.getLinuxCommand(
image,
parameters,
overrideCommands,
additionalVariables,
entrypointBash,
);
runCommand = this.getLinuxCommand(image, parameters, overrideCommands, additionalVariables, entrypointBash);
break;
case 'win32':
runCommand = this.getWindowsCommand(image, parameters);
@@ -48,7 +42,6 @@ class Docker {
const {
workspace,
actionFolder,
useHostNetwork,
runnerTempPath,
sshAgent,
sshPublicKeysDirectoryPath,
@@ -92,7 +85,6 @@ class Docker {
: ''
} \
${sshPublicKeysDirectoryPath ? `--volume ${sshPublicKeysDirectoryPath}:/root/.ssh:ro` : ''} \
${useHostNetwork ? '--net=host' : ''} \
${entrypointBash ? `--entrypoint ${commandPrefix}` : ``} \
${image} \
${entrypointBash ? `-c` : `${commandPrefix} -c`} \

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,222 @@
import OrchestratorLogger from './orchestrator/services/core/orchestrator-logger';
import Orchestrator from './orchestrator/orchestrator';
import OrchestratorOptions from './orchestrator/options/orchestrator-options';
import * as core from '@actions/core';
import { Octokit } from '@octokit/core';
class GitHub {
private static readonly asyncChecksApiWorkflowName = `Async Checks API`;
public static githubInputEnabled: boolean = true;
private static longDescriptionContent: string = ``;
private static startedDate: string;
private static endedDate: string;
static result: string = ``;
static forceAsyncTest: boolean;
private static get octokitDefaultToken() {
return new Octokit({
auth: process.env.GITHUB_TOKEN,
});
}
private static get octokitPAT() {
return new Octokit({
auth: Orchestrator.buildParameters.gitPrivateToken,
});
}
private static get sha() {
return Orchestrator.buildParameters.gitSha;
}
private static get checkName() {
return `Orchestrator (${Orchestrator.buildParameters.buildGuid})`;
}
private static get nameReadable() {
return GitHub.checkName;
}
private static get checkRunId() {
return Orchestrator.buildParameters.githubCheckId;
}
private static get owner() {
return OrchestratorOptions.githubOwner;
}
private static get repo() {
return OrchestratorOptions.githubRepoName;
}
public static async createGitHubCheck(summary: string) {
if (!Orchestrator.buildParameters.githubChecks) {
return ``;
}
GitHub.startedDate = new Date().toISOString();
OrchestratorLogger.log(`Creating github check`);
const data = {
owner: GitHub.owner,
repo: GitHub.repo,
name: GitHub.checkName,
// eslint-disable-next-line camelcase
head_sha: GitHub.sha,
status: 'queued',
// eslint-disable-next-line camelcase
external_id: Orchestrator.buildParameters.buildGuid,
// eslint-disable-next-line camelcase
started_at: GitHub.startedDate,
output: {
title: GitHub.nameReadable,
summary,
text: '',
images: [
{
alt: 'Game-CI',
// eslint-disable-next-line camelcase
image_url: 'https://game.ci/assets/images/game-ci-brand-logo-wordmark.svg',
},
],
},
};
const result = await GitHub.createGitHubCheckRequest(data);
OrchestratorLogger.log(`Creating github check ${result.status}`);
return result.data.id.toString();
}
public static async updateGitHubCheck(
longDescription: string,
summary: string,
result = `neutral`,
status = `in_progress`,
) {
if (`${Orchestrator.buildParameters.githubChecks}` !== `true`) {
return;
}
OrchestratorLogger.log(
`githubChecks: ${Orchestrator.buildParameters.githubChecks} checkRunId: ${GitHub.checkRunId} sha: ${GitHub.sha} async: ${Orchestrator.isOrchestratorAsyncEnvironment}`,
);
GitHub.longDescriptionContent += `\n${longDescription}`;
if (GitHub.result !== `success` && GitHub.result !== `failure`) {
GitHub.result = result;
} else {
result = GitHub.result;
}
const data: any = {
owner: GitHub.owner,
repo: GitHub.repo,
// eslint-disable-next-line camelcase
check_run_id: GitHub.checkRunId,
name: GitHub.checkName,
// eslint-disable-next-line camelcase
head_sha: GitHub.sha,
// eslint-disable-next-line camelcase
started_at: GitHub.startedDate,
status,
output: {
title: GitHub.nameReadable,
summary,
text: GitHub.longDescriptionContent,
annotations: [],
},
};
if (status === `completed`) {
if (GitHub.endedDate !== undefined) {
GitHub.endedDate = new Date().toISOString();
}
// eslint-disable-next-line camelcase
data.completed_at = GitHub.endedDate || GitHub.startedDate;
data.conclusion = result;
}
await (Orchestrator.isOrchestratorAsyncEnvironment || GitHub.forceAsyncTest
? GitHub.runUpdateAsyncChecksWorkflow(data, `update`)
: GitHub.updateGitHubCheckRequest(data));
}
public static async updateGitHubCheckRequest(data: any) {
return await GitHub.octokitDefaultToken.request(`PATCH /repos/{owner}/{repo}/check-runs/{check_run_id}`, data);
}
public static async createGitHubCheckRequest(data: any) {
return await GitHub.octokitDefaultToken.request(`POST /repos/{owner}/{repo}/check-runs`, data);
}
public static async runUpdateAsyncChecksWorkflow(data: any, mode: string) {
if (mode === `create`) {
throw new Error(`Not supported: only use update`);
}
const workflowsResult = await GitHub.octokitPAT.request(`GET /repos/{owner}/{repo}/actions/workflows`, {
owner: GitHub.owner,
repo: GitHub.repo,
});
const workflows = workflowsResult.data.workflows;
OrchestratorLogger.log(`Got ${workflows.length} workflows`);
let selectedId = ``;
for (let index = 0; index < workflowsResult.data.total_count; index++) {
if (workflows[index].name === GitHub.asyncChecksApiWorkflowName) {
selectedId = workflows[index].id.toString();
}
}
if (selectedId === ``) {
core.info(JSON.stringify(workflows));
throw new Error(`no workflow with name "${GitHub.asyncChecksApiWorkflowName}"`);
}
await GitHub.octokitPAT.request(`POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`, {
owner: GitHub.owner,
repo: GitHub.repo,
// eslint-disable-next-line camelcase
workflow_id: selectedId,
ref: OrchestratorOptions.branch,
inputs: {
checksObject: JSON.stringify({ data, mode }),
},
});
}
static async triggerWorkflowOnComplete(triggerWorkflowOnComplete: string[]) {
const isLocalAsync = Orchestrator.buildParameters.asyncWorkflow && !Orchestrator.isOrchestratorAsyncEnvironment;
if (isLocalAsync || triggerWorkflowOnComplete === undefined || triggerWorkflowOnComplete.length === 0) {
return;
}
try {
const workflowsResult = await GitHub.octokitPAT.request(`GET /repos/{owner}/{repo}/actions/workflows`, {
owner: GitHub.owner,
repo: GitHub.repo,
});
const workflows = workflowsResult.data.workflows;
OrchestratorLogger.log(`Got ${workflows.length} workflows`);
for (const element of triggerWorkflowOnComplete) {
let selectedId = ``;
for (let index = 0; index < workflowsResult.data.total_count; index++) {
if (workflows[index].name === element) {
selectedId = workflows[index].id.toString();
}
}
if (selectedId === ``) {
core.info(JSON.stringify(workflows));
throw new Error(`no workflow with name "${GitHub.asyncChecksApiWorkflowName}"`);
}
await GitHub.octokitPAT.request(`POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`, {
owner: GitHub.owner,
repo: GitHub.repo,
// eslint-disable-next-line camelcase
workflow_id: selectedId,
ref: OrchestratorOptions.branch,
inputs: {
buildGuid: Orchestrator.buildParameters.buildGuid,
},
});
}
} catch {
core.info(`github workflow complete hook not found`);
}
}
public static async getCheckStatus() {
return await GitHub.octokitDefaultToken.request(`GET /repos/{owner}/{repo}/check-runs/{check_run_id}`);
}
}
export default GitHub;

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ import Platform from './platform';
import Project from './project';
import Unity from './unity';
import Versioning from './versioning';
import Orchestrator from './orchestrator/orchestrator';
import loadProvider, { ProviderLoader } from './orchestrator/providers/provider-loader';
export {
Action,
@@ -22,4 +24,7 @@ export {
Project,
Unity,
Versioning,
Orchestrator as Orchestrator,
loadProvider,
ProviderLoader,
};

View File

@@ -1,21 +1,12 @@
import { exec } from 'node:child_process';
import Input from '../input';
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
export class GenericInputReader {
public static async Run(command: string) {
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
if (OrchestratorOptions.providerStrategy === 'local') {
return '';
}
return new Promise<string>((resolve, reject) => {
exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout.toString());
});
});
return await OrchestratorSystem.Run(command, false, true);
}
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest';
import { GitRepoReader } from './git-repo';
import Input from '../input';
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
describe(`git repo tests`, () => {
it(`Branch value parsed from CLI to not contain illegal characters`, async () => {
@@ -10,15 +10,15 @@ describe(`git repo tests`, () => {
it(`returns valid branch name when using https`, async () => {
const mockValue = 'https://github.com/example/example.git';
vi.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
vi.spyOn(Input, 'getInput').mockReturnValue('not-local');
await jest.spyOn(OrchestratorSystem, 'Run').mockReturnValue(Promise.resolve(mockValue));
await jest.spyOn(OrchestratorOptions, 'providerStrategy', 'get').mockReturnValue('not-local');
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
});
it(`returns valid branch name when using ssh`, async () => {
const mockValue = 'git@github.com:example/example.git';
vi.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
vi.spyOn(Input, 'getInput').mockReturnValue('not-local');
await jest.spyOn(OrchestratorSystem, 'Run').mockReturnValue(Promise.resolve(mockValue));
await jest.spyOn(OrchestratorOptions, 'providerStrategy', 'get').mockReturnValue('not-local');
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
});
});

View File

@@ -1,44 +1,33 @@
import { assert } from 'node:console';
import fs from 'node:fs';
import { exec } from 'node:child_process';
import * as core from '@actions/core';
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger';
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
import Input from '../input';
export class GitRepoReader {
private static async runCommand(command: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout.toString());
});
});
}
public static async GetRemote() {
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
if (OrchestratorOptions.providerStrategy === 'local') {
return '';
}
assert(fs.existsSync(`.git`));
const value = (
await GitRepoReader.runCommand(`cd ${Input.projectPath} && git remote -v`)
).replace(/ /g, ``);
core.info(`value ${value}`);
const value = (await OrchestratorSystem.Run(`cd ${Input.projectPath} && git remote -v`, false, true)).replace(
/ /g,
``,
);
OrchestratorLogger.log(`value ${value}`);
assert(value.includes('github.com'));
return value.split('github.com')[1].split('.git')[0].slice(1);
}
public static async GetBranch() {
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
if (OrchestratorOptions.providerStrategy === 'local') {
return '';
}
assert(fs.existsSync(`.git`));
return (await GitRepoReader.runCommand(`cd ${Input.projectPath} && git branch --show-current`))
return (await OrchestratorSystem.Run(`cd ${Input.projectPath} && git branch --show-current`, false, true))
.split('\n')[0]
.replace(/ /g, ``)
.replace('/head', '');

View File

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

View File

@@ -1,32 +1,19 @@
import { exec } from 'node:child_process';
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
import * as core from '@actions/core';
import Input from '../input';
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
export class GithubCliReader {
private static async runCommand(command: string, suppressError = false): Promise<string> {
return new Promise<string>((resolve, reject) => {
exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout, stderr) => {
if (error && !suppressError) {
reject(error);
return;
}
resolve((stdout || '').toString() + (stderr || '').toString());
});
});
}
static async GetGitHubAuthToken() {
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
if (OrchestratorOptions.providerStrategy === 'local') {
return '';
}
try {
const authStatus = await GithubCliReader.runCommand(`gh auth status`, true);
const authStatus = await OrchestratorSystem.Run(`gh auth status`, true, true);
if (authStatus.includes('You are not logged') || authStatus === '') {
return '';
}
return (await GithubCliReader.runCommand(`gh auth status -t`))
return (await OrchestratorSystem.Run(`gh auth status -t`, false, true))
.split(`Token: `)[1]
.replace(/ /g, '')
.replace(/\n/g, '');

View File

@@ -1,20 +1,13 @@
import path from 'node:path';
import fs from 'node:fs';
import YAML from 'yaml';
import Input from '../input';
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
export function ReadLicense(): string {
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
if (OrchestratorOptions.providerStrategy === 'local') {
return '';
}
const pipelineFile = path.join(
__dirname,
`.github`,
`workflows`,
`orchestrator-k8s-pipeline.yml`,
);
const pipelineFile = path.join(__dirname, `.github`, `workflows`, `orchestrator-k8s-pipeline.yml`);
return fs.existsSync(pipelineFile)
? YAML.parse(fs.readFileSync(pipelineFile, 'utf8')).env.UNITY_LICENSE
: '';
return fs.existsSync(pipelineFile) ? YAML.parse(fs.readFileSync(pipelineFile, 'utf8')).env.UNITY_LICENSE : '';
}

View File

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

View File

@@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { Cli } from './cli/cli';
import OrchestratorQueryOverride from './orchestrator/options/orchestrator-query-override';
import Platform from './platform';
import GitHub from './github';
import os from 'node:os';
@@ -14,8 +15,7 @@ export type InputKey = keyof typeof Input;
*
* Note that input is always passed as a string, even booleans.
*
* Only core build inputs belong here. Orchestrator/plugin inputs are read
* directly by the @game-ci/orchestrator plugin via core.getInput() / env vars.
* Todo: rename to UserInput and remove anything that is not direct input from the user / ci workflow
*/
class Input {
public static getInput(query: string): string | undefined {
@@ -32,6 +32,10 @@ class Input {
return Cli.query(query, alternativeQuery);
}
if (OrchestratorQueryOverride.query(query, alternativeQuery)) {
return OrchestratorQueryOverride.query(query, alternativeQuery);
}
if (process.env[query] !== undefined) {
return process.env[query]!;
}
@@ -41,16 +45,17 @@ class Input {
}
}
static get region(): string {
return Input.getInput('region') ?? 'eu-west-2';
}
static get githubRepo(): string | undefined {
return Input.getInput('GITHUB_REPOSITORY') ?? Input.getInput('GITHUB_REPO') ?? undefined;
}
static get branch(): string {
if (Input.getInput(`GITHUB_REF`)) {
return Input.getInput(`GITHUB_REF`)!
.replace('refs/', '')
.replace(`head/`, '')
.replace(`heads/`, '');
return Input.getInput(`GITHUB_REF`)!.replace('refs/', '').replace(`head/`, '').replace(`heads/`, '');
} else if (Input.getInput('branch')) {
return Input.getInput('branch')!;
} else {
@@ -142,12 +147,6 @@ class Input {
return Input.getInput('customParameters') ?? '';
}
static get useHostNetwork(): boolean {
const input = Input.getInput('useHostNetwork') ?? false;
return input === 'true';
}
static get versioningStrategy(): string {
return Input.getInput('versioning') ?? 'Semantic';
}
@@ -263,8 +262,7 @@ class Input {
}
return (
Input.getInput('dockerMemoryLimit') ??
`${Math.floor((os.totalmem() / bytesInMegabyte) * memoryMultiplier)}m`
Input.getInput('dockerMemoryLimit') ?? `${Math.floor((os.totalmem() / bytesInMegabyte) * memoryMultiplier)}m`
);
}
@@ -284,6 +282,276 @@ class Input {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
static get submoduleProfilePath(): string {
return Input.getInput('submoduleProfilePath') ?? '';
}
static get submoduleVariantPath(): string {
return Input.getInput('submoduleVariantPath') ?? '';
}
static get submoduleToken(): string {
return Input.getInput('submoduleToken') ?? '';
}
static get localCacheEnabled(): boolean {
return (Input.getInput('localCacheEnabled') ?? 'false') === 'true';
}
static get localCacheRoot(): string {
return Input.getInput('localCacheRoot') ?? '';
}
static get localCacheLibrary(): boolean {
return (Input.getInput('localCacheLibrary') ?? 'true') === 'true';
}
static get localCacheLfs(): boolean {
return (Input.getInput('localCacheLfs') ?? 'false') === 'true';
}
static get childWorkspacesEnabled(): boolean {
return (Input.getInput('childWorkspacesEnabled') ?? 'false') === 'true';
}
static get childWorkspaceName(): string {
return Input.getInput('childWorkspaceName') ?? '';
}
static get childWorkspaceCacheRoot(): string {
return Input.getInput('childWorkspaceCacheRoot') ?? '';
}
static get childWorkspacePreserveGit(): boolean {
return (Input.getInput('childWorkspacePreserveGit') ?? 'true') === 'true';
}
static get childWorkspaceSeparateLibrary(): boolean {
return (Input.getInput('childWorkspaceSeparateLibrary') ?? 'true') === 'true';
}
static get lfsTransferAgent(): string {
return Input.getInput('lfsTransferAgent') ?? '';
}
static get lfsTransferAgentArgs(): string {
return Input.getInput('lfsTransferAgentArgs') ?? '';
}
static get lfsStoragePaths(): string {
return Input.getInput('lfsStoragePaths') ?? '';
}
static get gitHooksEnabled(): boolean {
return (Input.getInput('gitHooksEnabled') ?? 'false') === 'true';
}
static get gitHooksSkipList(): string {
return Input.getInput('gitHooksSkipList') ?? '';
}
static get gitHooksRunBeforeBuild(): string {
return Input.getInput('gitHooksRunBeforeBuild') ?? '';
}
static get providerExecutable(): string {
return Input.getInput('providerExecutable') ?? '';
}
static get gitIntegrityCheck(): boolean {
const input = Input.getInput('gitIntegrityCheck') ?? 'false';
return input === 'true';
}
static get gitAutoRecover(): boolean {
const input = Input.getInput('gitAutoRecover') ?? 'false';
return input === 'true';
}
static get cleanReservedFilenames(): boolean {
const input = Input.getInput('cleanReservedFilenames') ?? 'false';
return input === 'true';
}
static get buildArchiveEnabled(): boolean {
const input = Input.getInput('buildArchiveEnabled') ?? 'false';
return input === 'true';
}
static get buildArchivePath(): string {
return Input.getInput('buildArchivePath') ?? './build-archives';
}
static get buildArchiveRetention(): number {
return Number.parseInt(Input.getInput('buildArchiveRetention') ?? '30', 10);
}
// GCP Cloud Run (Experimental)
static get gcpProject(): string {
return Input.getInput('gcpProject') ?? '';
}
static get gcpRegion(): string {
return Input.getInput('gcpRegion') ?? '';
}
static get gcpStorageType(): string {
return Input.getInput('gcpStorageType') ?? 'gcs-fuse';
}
static get gcpBucket(): string {
return Input.getInput('gcpBucket') ?? '';
}
static get gcpFilestoreIp(): string {
return Input.getInput('gcpFilestoreIp') ?? '';
}
static get gcpFilestoreShare(): string {
return Input.getInput('gcpFilestoreShare') ?? '/share1';
}
static get gcpMachineType(): string {
return Input.getInput('gcpMachineType') ?? 'e2-standard-4';
}
static get gcpDiskSizeGb(): string {
return Input.getInput('gcpDiskSizeGb') ?? '100';
}
static get gcpServiceAccount(): string {
return Input.getInput('gcpServiceAccount') ?? '';
}
static get gcpVpcConnector(): string {
return Input.getInput('gcpVpcConnector') ?? '';
}
// Azure Container Instances (Experimental)
static get azureResourceGroup(): string {
return Input.getInput('azureResourceGroup') ?? '';
}
static get azureLocation(): string {
return Input.getInput('azureLocation') ?? '';
}
static get azureStorageType(): string {
return Input.getInput('azureStorageType') ?? 'azure-files';
}
static get azureStorageAccount(): string {
return Input.getInput('azureStorageAccount') ?? '';
}
static get azureBlobContainer(): string {
return Input.getInput('azureBlobContainer') ?? 'unity-builds';
}
static get azureFileShareName(): string {
return Input.getInput('azureFileShareName') ?? 'unity-builds';
}
static get azureSubscriptionId(): string {
return Input.getInput('azureSubscriptionId') ?? '';
}
static get azureCpu(): string {
return Input.getInput('azureCpu') ?? '4';
}
static get azureMemoryGb(): string {
return Input.getInput('azureMemoryGb') ?? '16';
}
static get azureDiskSizeGb(): string {
return Input.getInput('azureDiskSizeGb') ?? '100';
}
static get azureSubnetId(): string {
return Input.getInput('azureSubnetId') ?? '';
}
// ### ### ###
// Remote PowerShell provider
// ### ### ###
static get remotePowershellHost(): string {
return Input.getInput('remotePowershellHost') ?? '';
}
static get remotePowershellCredential(): string {
return Input.getInput('remotePowershellCredential') ?? '';
}
static get remotePowershellTransport(): string {
return Input.getInput('remotePowershellTransport') ?? 'wsman';
}
// ### ### ###
// GitHub Actions provider
// ### ### ###
static get githubActionsRepo(): string {
return Input.getInput('githubActionsRepo') ?? '';
}
static get githubActionsWorkflow(): string {
return Input.getInput('githubActionsWorkflow') ?? '';
}
static get githubActionsToken(): string {
return Input.getInput('githubActionsToken') ?? '';
}
static get githubActionsRef(): string {
return Input.getInput('githubActionsRef') ?? 'main';
}
// ### ### ###
// GitLab CI provider
// ### ### ###
static get gitlabProjectId(): string {
return Input.getInput('gitlabProjectId') ?? '';
}
static get gitlabTriggerToken(): string {
return Input.getInput('gitlabTriggerToken') ?? '';
}
static get gitlabApiUrl(): string {
return Input.getInput('gitlabApiUrl') ?? 'https://gitlab.com';
}
static get gitlabRef(): string {
return Input.getInput('gitlabRef') ?? 'main';
}
// ### ### ###
// Ansible provider
// ### ### ###
static get ansibleInventory(): string {
return Input.getInput('ansibleInventory') ?? '';
}
static get ansiblePlaybook(): string {
return Input.getInput('ansiblePlaybook') ?? '';
}
static get ansibleExtraVars(): string {
return Input.getInput('ansibleExtraVars') ?? '';
}
static get ansibleVaultPassword(): string {
return Input.getInput('ansibleVaultPassword') ?? '';
}
public static ToEnvVarFormat(input: string) {
if (input.toUpperCase() === input) {
return input;

View File

@@ -1,17 +0,0 @@
import { describe, expect, it } from 'vitest';
/**
* Compatibility tests for the legacy orchestrator-plugin module name.
*
* CI targets this file pattern directly, and consumers may still import this
* module while migrating to the generic plugin API.
*/
describe('orchestrator-plugin compatibility exports', () => {
it('keeps loadOrchestratorPlugin as an alias for loadPlugin', async () => {
const plugin = await import('./plugin');
const compatibility = await import('./orchestrator-plugin');
expect(compatibility.loadOrchestratorPlugin).toBe(plugin.loadPlugin);
});
});

View File

@@ -1,2 +0,0 @@
export { loadPlugin as loadOrchestratorPlugin } from './plugin';
export type { Plugin as OrchestratorPlugin } from './plugin';

View File

@@ -0,0 +1,15 @@
import OrchestratorLogger from '../services/core/orchestrator-logger';
import * as core from '@actions/core';
import Orchestrator from '../orchestrator';
import OrchestratorSecret from '../options/orchestrator-secret';
import BuildParameters from '../../build-parameters';
export class OrchestratorError {
public static async handleException(error: unknown, buildParameters: BuildParameters, secrets: OrchestratorSecret[]) {
OrchestratorLogger.error(JSON.stringify(error, undefined, 4));
core.setFailed('Orchestrator failed');
if (Orchestrator.Provider !== undefined) {
await Orchestrator.Provider.cleanupWorkflow(buildParameters, buildParameters.branch, secrets);
}
}
}

View File

@@ -0,0 +1,4 @@
class OrchestratorConstants {
static alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
}
export default OrchestratorConstants;

View File

@@ -0,0 +1,5 @@
class OrchestratorEnvironmentVariable {
public name!: string;
public value!: string;
}
export default OrchestratorEnvironmentVariable;

View File

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

View File

@@ -0,0 +1,162 @@
import { OrchestratorFolders } from './orchestrator-folders';
// Mock Orchestrator
jest.mock('../orchestrator', () => ({
__esModule: true,
default: {
buildParameters: {
buildGuid: 'test-guid-abc',
cacheKey: 'my-cache-key',
projectPath: 'test-project',
buildPath: 'Builds',
maxRetainedWorkspaces: 0,
gitPrivateToken: 'ghp_test123',
gitAuthMode: 'url',
orchestratorRepoName: 'game-ci/unity-builder',
githubRepo: 'user/my-game',
},
lockedWorkspace: '',
},
}));
jest.mock('../../build-parameters', () => ({
__esModule: true,
default: {
shouldUseRetainedWorkspaceMode: jest.fn().mockReturnValue(false),
},
}));
jest.mock('./orchestrator-options', () => ({
__esModule: true,
default: {
useSharedBuilder: false,
},
}));
// Normalize paths for cross-platform test compatibility
const normalize = (p: string) => p.replace(/\\/g, '/');
describe('OrchestratorFolders', () => {
describe('static constants', () => {
it('repositoryFolder is "repo"', () => {
expect(OrchestratorFolders.repositoryFolder).toBe('repo');
});
it('buildVolumeFolder is "data"', () => {
expect(OrchestratorFolders.buildVolumeFolder).toBe('data');
});
it('cacheFolder is "cache"', () => {
expect(OrchestratorFolders.cacheFolder).toBe('cache');
});
});
describe('ToLinuxFolder', () => {
it('converts backslashes to forward slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('C:\\Users\\test\\project')).toBe('C:/Users/test/project');
});
it('preserves forward slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('/home/user/project')).toBe('/home/user/project');
});
it('handles mixed slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('some/path\\mixed/slashes\\here')).toBe('some/path/mixed/slashes/here');
});
it('handles empty string', () => {
expect(OrchestratorFolders.ToLinuxFolder('')).toBe('');
});
});
describe('path computations (non-retained workspace mode)', () => {
it('uniqueOrchestratorJobFolderAbsolute uses buildGuid', () => {
const result = normalize(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute);
expect(result).toBe('/data/test-guid-abc');
});
it('cacheFolderForAllFull returns /data/cache', () => {
const result = normalize(OrchestratorFolders.cacheFolderForAllFull);
expect(result).toBe('/data/cache');
});
it('cacheFolderForCacheKeyFull includes cache key', () => {
const result = normalize(OrchestratorFolders.cacheFolderForCacheKeyFull);
expect(result).toBe('/data/cache/my-cache-key');
});
it('repoPathAbsolute is under job folder', () => {
const result = normalize(OrchestratorFolders.repoPathAbsolute);
expect(result).toBe('/data/test-guid-abc/repo');
});
it('projectPathAbsolute includes project path', () => {
const result = normalize(OrchestratorFolders.projectPathAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/test-project');
});
it('libraryFolderAbsolute is under project path', () => {
const result = normalize(OrchestratorFolders.libraryFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/test-project/Library');
});
it('projectBuildFolderAbsolute uses buildPath', () => {
const result = normalize(OrchestratorFolders.projectBuildFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/Builds');
});
it('lfsFolderAbsolute is under .git/lfs', () => {
const result = normalize(OrchestratorFolders.lfsFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/.git/lfs');
});
it('lfsCacheFolderFull is under cache key', () => {
const result = normalize(OrchestratorFolders.lfsCacheFolderFull);
expect(result).toBe('/data/cache/my-cache-key/lfs');
});
it('libraryCacheFolderFull is under cache key', () => {
const result = normalize(OrchestratorFolders.libraryCacheFolderFull);
expect(result).toBe('/data/cache/my-cache-key/Library');
});
});
describe('builderPathAbsolute', () => {
it('uses job folder when shared builder is disabled', () => {
const result = normalize(OrchestratorFolders.builderPathAbsolute);
expect(result).toBe('/data/test-guid-abc/builder');
});
});
describe('repo URLs', () => {
it('unityBuilderRepoUrl includes token and repo name', () => {
const url = OrchestratorFolders.unityBuilderRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/game-ci/unity-builder.git');
});
it('targetBuildRepoUrl includes token and github repo', () => {
const url = OrchestratorFolders.targetBuildRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/user/my-game.git');
});
});
describe('purgeRemoteCaching', () => {
it('returns false when env var is not set', () => {
const original = process.env.PURGE_REMOTE_BUILDER_CACHE;
delete process.env.PURGE_REMOTE_BUILDER_CACHE;
expect(OrchestratorFolders.purgeRemoteCaching).toBe(false);
if (original !== undefined) process.env.PURGE_REMOTE_BUILDER_CACHE = original;
});
it('returns true when env var is set', () => {
const original = process.env.PURGE_REMOTE_BUILDER_CACHE;
process.env.PURGE_REMOTE_BUILDER_CACHE = 'true';
expect(OrchestratorFolders.purgeRemoteCaching).toBe(true);
if (original !== undefined) {
process.env.PURGE_REMOTE_BUILDER_CACHE = original;
} else {
delete process.env.PURGE_REMOTE_BUILDER_CACHE;
}
});
});
});

View File

@@ -0,0 +1,143 @@
import path from 'node:path';
import OrchestratorOptions from './orchestrator-options';
import Orchestrator from '../orchestrator';
import BuildParameters from '../../build-parameters';
export class OrchestratorFolders {
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 uniqueOrchestratorJobFolderAbsolute(): string {
return Orchestrator.buildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(Orchestrator.buildParameters)
? path.join(`/`, OrchestratorFolders.buildVolumeFolder, Orchestrator.lockedWorkspace)
: path.join(`/`, OrchestratorFolders.buildVolumeFolder, Orchestrator.buildParameters.buildGuid);
}
public static get cacheFolderForAllFull(): string {
return path.join('/', OrchestratorFolders.buildVolumeFolder, OrchestratorFolders.cacheFolder);
}
public static get cacheFolderForCacheKeyFull(): string {
return path.join(
'/',
OrchestratorFolders.buildVolumeFolder,
OrchestratorFolders.cacheFolder,
Orchestrator.buildParameters.cacheKey,
);
}
public static get builderPathAbsolute(): string {
return path.join(
OrchestratorOptions.useSharedBuilder
? `/${OrchestratorFolders.buildVolumeFolder}`
: OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute,
`builder`,
);
}
public static get repoPathAbsolute(): string {
return path.join(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute, OrchestratorFolders.repositoryFolder);
}
public static get projectPathAbsolute(): string {
return path.join(OrchestratorFolders.repoPathAbsolute, Orchestrator.buildParameters.projectPath);
}
public static get libraryFolderAbsolute(): string {
return path.join(OrchestratorFolders.projectPathAbsolute, `Library`);
}
public static get projectBuildFolderAbsolute(): string {
return path.join(OrchestratorFolders.repoPathAbsolute, Orchestrator.buildParameters.buildPath);
}
public static get lfsFolderAbsolute(): string {
return path.join(OrchestratorFolders.repoPathAbsolute, `.git`, `lfs`);
}
public static get purgeRemoteCaching(): boolean {
return process.env.PURGE_REMOTE_BUILDER_CACHE !== undefined;
}
public static get lfsCacheFolderFull() {
return path.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `lfs`);
}
public static get libraryCacheFolderFull() {
return path.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `Library`);
}
/**
* Whether to use http.extraHeader for git authentication (secure, default)
* instead of embedding the token in clone URLs (legacy).
*/
public static get useHeaderAuth(): boolean {
return Orchestrator.buildParameters.gitAuthMode !== 'url';
}
public static get unityBuilderRepoUrl(): string {
if (OrchestratorFolders.useHeaderAuth) {
return `https://github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
}
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
}
public static get targetBuildRepoUrl(): string {
if (OrchestratorFolders.useHeaderAuth) {
return `https://github.com/${Orchestrator.buildParameters.githubRepo}.git`;
}
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.githubRepo}.git`;
}
/**
* Shell commands to configure git authentication via http.extraHeader.
* Uses GIT_PRIVATE_TOKEN env var so the token never appears in clone URLs or git config output.
* This is the same mechanism used by actions/checkout.
*
* Only emits commands when gitAuthMode is 'header' (default). In 'url' mode,
* returns a no-op comment since the token is already in the URL.
*/
public static get gitAuthConfigScript(): string {
if (!OrchestratorFolders.useHeaderAuth) {
return `# git auth: using token-in-URL mode (legacy)`;
}
return `# git auth: configuring http.extraHeader (secure mode)
if [ -n "$GIT_PRIVATE_TOKEN" ]; then
git config --global http.https://github.com/.extraHeader "Authorization: Basic $(printf '%s' "x-access-token:$GIT_PRIVATE_TOKEN" | base64 -w 0)"
fi`;
}
/**
* Configure git authentication via http.extraHeader in the current Node process.
* For use in the remote-client where shell scripts aren't used.
* Only configures when gitAuthMode is 'header' (default).
*/
public static async configureGitAuth(): Promise<void> {
if (!OrchestratorFolders.useHeaderAuth) return;
const token = Orchestrator.buildParameters.gitPrivateToken || process.env.GIT_PRIVATE_TOKEN || '';
if (!token) return;
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
const { OrchestratorSystem } = await import('../services/core/orchestrator-system');
await OrchestratorSystem.Run(
`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`,
);
}
public static get buildVolumeFolder() {
return 'data';
}
public static get cacheFolder() {
return 'cache';
}
}

View File

@@ -0,0 +1,53 @@
import OrchestratorNamespace from './orchestrator-guid';
describe('OrchestratorNamespace', () => {
describe('generateGuid', () => {
it('generates a guid with correct format', () => {
const guid = OrchestratorNamespace.generateGuid('42', 'StandaloneLinux64');
// Format: {runNumber}-{platform}-{nanoid4}
expect(guid).toMatch(/^42-linux64-[a-z0-9]{4}$/);
});
it('strips "standalone" prefix from platform (case-insensitive)', () => {
const guid = OrchestratorNamespace.generateGuid('1', 'StandaloneWindows64');
expect(guid).toMatch(/^1-windows64-[a-z0-9]{4}$/);
});
it('lowercases platform name', () => {
const guid = OrchestratorNamespace.generateGuid('5', 'Android');
expect(guid).toMatch(/^5-android-[a-z0-9]{4}$/);
});
it('handles numeric run number', () => {
const guid = OrchestratorNamespace.generateGuid(100, 'iOS');
expect(guid).toMatch(/^100-ios-[a-z0-9]{4}$/);
});
it('generates unique guids on repeated calls', () => {
const guids = new Set<string>();
for (let i = 0; i < 20; i++) {
guids.add(OrchestratorNamespace.generateGuid('1', 'StandaloneLinux64'));
}
// With 4 alphanumeric chars (36^4 = ~1.7M possibilities), 20 calls should almost certainly be unique
expect(guids.size).toBeGreaterThan(1);
});
it('handles StandaloneOSX platform', () => {
const guid = OrchestratorNamespace.generateGuid('7', 'StandaloneOSX');
expect(guid).toMatch(/^7-osx-[a-z0-9]{4}$/);
});
it('handles WebGL platform (no standalone prefix)', () => {
const guid = OrchestratorNamespace.generateGuid('3', 'WebGL');
expect(guid).toMatch(/^3-webgl-[a-z0-9]{4}$/);
});
it('uses only lowercase alphanumeric characters in nanoid portion', () => {
for (let i = 0; i < 10; i++) {
const guid = OrchestratorNamespace.generateGuid('1', 'test');
const nanoidPart = guid.split('-').pop()!;
expect(nanoidPart).toMatch(/^[0-9a-z]{4}$/);
}
});
});
});

View File

@@ -0,0 +1,11 @@
import { customAlphabet } from 'nanoid';
import OrchestratorConstants from './orchestrator-constants';
class OrchestratorNamespace {
static generateGuid(runNumber: string | number, platform: string) {
const nanoid = customAlphabet(OrchestratorConstants.alphabet, 4);
return `${runNumber}-${platform.toLowerCase().replace('standalone', '')}-${nanoid()}`;
}
}
export default OrchestratorNamespace;

View File

@@ -0,0 +1,10 @@
import Input from '../../input';
import OrchestratorOptions from './orchestrator-options';
class OrchestratorOptionsReader {
static GetProperties() {
return [...Object.getOwnPropertyNames(Input), ...Object.getOwnPropertyNames(OrchestratorOptions)];
}
}
export default OrchestratorOptionsReader;

View File

@@ -0,0 +1,372 @@
import { Cli } from '../../cli/cli';
import OrchestratorQueryOverride from './orchestrator-query-override';
import GitHub from '../../github';
import * as core from '@actions/core';
class OrchestratorOptions {
// ### ### ###
// Input Handling
// ### ### ###
public static getInput(query: string): string | undefined {
if (GitHub.githubInputEnabled) {
const coreInput = core.getInput(query);
if (coreInput && coreInput !== '') {
return coreInput;
}
}
const alternativeQuery = OrchestratorOptions.ToEnvVarFormat(query);
// Query input sources
if (Cli.query(query, alternativeQuery)) {
return Cli.query(query, alternativeQuery);
}
if (OrchestratorQueryOverride.query(query, alternativeQuery)) {
return OrchestratorQueryOverride.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 OrchestratorOptions.getInput('region') || 'eu-west-2';
}
// ### ### ###
// GitHub parameters
// ### ### ###
static get githubChecks(): boolean {
const value = OrchestratorOptions.getInput('githubChecks');
return value === `true` || false;
}
static get githubCheckId(): string {
return OrchestratorOptions.getInput('githubCheckId') || ``;
}
static get githubOwner(): string {
return OrchestratorOptions.getInput('githubOwner') || OrchestratorOptions.githubRepo?.split(`/`)[0] || '';
}
static get githubRepoName(): string {
return OrchestratorOptions.getInput('githubRepoName') || OrchestratorOptions.githubRepo?.split(`/`)[1] || '';
}
static get orchestratorRepoName(): string {
return OrchestratorOptions.getInput('orchestratorRepoName') || 'game-ci/unity-builder';
}
static get cloneDepth(): string {
return OrchestratorOptions.getInput('cloneDepth') || '50';
}
static get finalHooks(): string[] {
return OrchestratorOptions.getInput('finalHooks')?.split(',') || [];
}
// ### ### ###
// Git syncronization parameters
// ### ### ###
static get githubRepo(): string | undefined {
return (
OrchestratorOptions.getInput('GITHUB_REPOSITORY') || OrchestratorOptions.getInput('GITHUB_REPO') || undefined
);
}
static get branch(): string {
if (OrchestratorOptions.getInput(`GITHUB_REF`)) {
return (
OrchestratorOptions.getInput(`GITHUB_REF`)?.replace('refs/', '').replace(`head/`, '').replace(`heads/`, '') ||
``
);
} else if (OrchestratorOptions.getInput('branch')) {
return OrchestratorOptions.getInput('branch') || ``;
} else {
return '';
}
}
// ### ### ###
// Orchestrator parameters
// ### ### ###
static get buildPlatform(): string {
const input = OrchestratorOptions.getInput('buildPlatform');
if (input && input !== '') {
return input;
}
if (OrchestratorOptions.providerStrategy !== 'local') {
return 'linux';
}
return process.platform;
}
static get orchestratorBranch(): string {
return OrchestratorOptions.getInput('orchestratorBranch') || 'main';
}
static get providerStrategy(): string {
const provider =
OrchestratorOptions.getInput('orchestratorCluster') || OrchestratorOptions.getInput('providerStrategy');
if (Cli.isCliMode) {
return provider || 'aws';
}
return provider || 'local';
}
static get gitAuthMode(): string {
return OrchestratorOptions.getInput('gitAuthMode') || 'header';
}
static get fallbackProviderStrategy(): string {
return OrchestratorOptions.getInput('fallbackProviderStrategy') || '';
}
static get runnerCheckEnabled(): boolean {
return OrchestratorOptions.getInput('runnerCheckEnabled') === 'true';
}
static get runnerCheckLabels(): string[] {
const labels = OrchestratorOptions.getInput('runnerCheckLabels');
return labels ? labels.split(',').map((l) => l.trim()) : [];
}
static get runnerCheckMinAvailable(): number {
return Number(OrchestratorOptions.getInput('runnerCheckMinAvailable')) || 1;
}
static get retryOnFallback(): boolean {
return OrchestratorOptions.getInput('retryOnFallback') === 'true';
}
static get providerInitTimeout(): number {
return Number(OrchestratorOptions.getInput('providerInitTimeout')) || 0;
}
static get containerCpu(): string {
return OrchestratorOptions.getInput('containerCpu') || `1024`;
}
static get containerMemory(): string {
return OrchestratorOptions.getInput('containerMemory') || `3072`;
}
static get containerNamespace(): string {
return OrchestratorOptions.getInput('containerNamespace') || `default`;
}
static get customJob(): string {
return OrchestratorOptions.getInput('customJob') || '';
}
// ### ### ###
// Custom commands from files parameters
// ### ### ###
static get containerHookFiles(): string[] {
return OrchestratorOptions.getInput('containerHookFiles')?.split(`,`) || [];
}
static get commandHookFiles(): string[] {
return OrchestratorOptions.getInput('commandHookFiles')?.split(`,`) || [];
}
// ### ### ###
// Custom commands from yaml parameters
// ### ### ###
static get commandHooks(): string {
return OrchestratorOptions.getInput('commandHooks') || '';
}
static get postBuildContainerHooks(): string {
return OrchestratorOptions.getInput('postBuildContainerHooks') || '';
}
static get preBuildContainerHooks(): string {
return OrchestratorOptions.getInput('preBuildContainerHooks') || '';
}
// ### ### ###
// Input override handling
// ### ### ###
static get pullInputList(): string[] {
return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || [];
}
static get secretSource(): string {
return OrchestratorOptions.getInput('secretSource') || '';
}
static get inputPullCommand(): string {
const value = OrchestratorOptions.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 OrchestratorOptions.getInput('awsStackName') || 'game-ci';
}
static get awsEndpoint(): string | undefined {
return OrchestratorOptions.getInput('awsEndpoint');
}
static get awsCloudFormationEndpoint(): string | undefined {
return OrchestratorOptions.getInput('awsCloudFormationEndpoint') || OrchestratorOptions.awsEndpoint;
}
static get awsEcsEndpoint(): string | undefined {
return OrchestratorOptions.getInput('awsEcsEndpoint') || OrchestratorOptions.awsEndpoint;
}
static get awsKinesisEndpoint(): string | undefined {
return OrchestratorOptions.getInput('awsKinesisEndpoint') || OrchestratorOptions.awsEndpoint;
}
static get awsCloudWatchLogsEndpoint(): string | undefined {
return OrchestratorOptions.getInput('awsCloudWatchLogsEndpoint') || OrchestratorOptions.awsEndpoint;
}
static get awsS3Endpoint(): string | undefined {
return OrchestratorOptions.getInput('awsS3Endpoint') || OrchestratorOptions.awsEndpoint;
}
// ### ### ###
// Storage
// ### ### ###
static get storageProvider(): string {
return OrchestratorOptions.getInput('storageProvider') || 's3';
}
static get rcloneRemote(): string {
return OrchestratorOptions.getInput('rcloneRemote') || '';
}
// ### ### ###
// K8s
// ### ### ###
static get kubeConfig(): string {
return OrchestratorOptions.getInput('kubeConfig') || '';
}
static get kubeVolume(): string {
return OrchestratorOptions.getInput('kubeVolume') || '';
}
static get kubeVolumeSize(): string {
return OrchestratorOptions.getInput('kubeVolumeSize') || '25Gi';
}
static get kubeStorageClass(): string {
return OrchestratorOptions.getInput('kubeStorageClass') || '';
}
// ### ### ###
// Caching
// ### ### ###
static get cacheKey(): string {
return OrchestratorOptions.getInput('cacheKey') || OrchestratorOptions.branch;
}
// ### ### ###
// Utility Parameters
// ### ### ###
static get orchestratorDebug(): boolean {
return (
OrchestratorOptions.getInput(`orchestratorTests`) === `true` ||
OrchestratorOptions.getInput(`orchestratorDebug`) === `true` ||
OrchestratorOptions.getInput(`orchestratorDebugTree`) === `true` ||
OrchestratorOptions.getInput(`orchestratorDebugEnv`) === `true` ||
false
);
}
static get skipLfs(): boolean {
return OrchestratorOptions.getInput(`skipLfs`) === `true`;
}
static get skipCache(): boolean {
return OrchestratorOptions.getInput(`skipCache`) === `true`;
}
public static get asyncOrchestrator(): boolean {
return OrchestratorOptions.getInput('asyncOrchestrator') === 'true';
}
public static get resourceTracking(): boolean {
return OrchestratorOptions.getInput('resourceTracking') === 'true';
}
public static get useLargePackages(): boolean {
return OrchestratorOptions.getInput(`useLargePackages`) === `true`;
}
public static get useSharedBuilder(): boolean {
return OrchestratorOptions.getInput(`useSharedBuilder`) === `true`;
}
public static get useCompressionStrategy(): boolean {
return OrchestratorOptions.getInput(`useCompressionStrategy`) === `true`;
}
public static get useCleanupCron(): boolean {
return (OrchestratorOptions.getInput(`useCleanupCron`) || 'true') === 'true';
}
// ### ### ###
// Retained Workspace
// ### ### ###
public static get maxRetainedWorkspaces(): string {
return OrchestratorOptions.getInput(`maxRetainedWorkspaces`) || `0`;
}
// ### ### ###
// Garbage Collection
// ### ### ###
static get garbageMaxAge(): number {
return Number(OrchestratorOptions.getInput(`garbageMaxAge`)) || 24;
}
}
export default OrchestratorOptions;

View File

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

View File

@@ -0,0 +1,6 @@
class OrchestratorSecret {
public ParameterKey!: string;
public EnvironmentVariable!: string;
public ParameterValue!: string;
}
export default OrchestratorSecret;

View File

@@ -0,0 +1,3 @@
export class OrchestratorStatics {
public static readonly logPrefix = `Orchestrator`;
}

View File

@@ -0,0 +1,13 @@
import OrchestratorEnvironmentVariable from './orchestrator-environment-variable';
import OrchestratorSecret from './orchestrator-secret';
export class OrchestratorStepParameters {
public image: string;
public environment: OrchestratorEnvironmentVariable[];
public secrets: OrchestratorSecret[];
constructor(image: string, environmentVariables: OrchestratorEnvironmentVariable[], secrets: OrchestratorSecret[]) {
this.image = image;
this.environment = environmentVariables;
this.secrets = secrets;
}
}

View File

@@ -0,0 +1,473 @@
import AwsBuildPlatform from './providers/aws';
import { BuildParameters, Input } from '..';
import Kubernetes from './providers/k8s';
import OrchestratorLogger from './services/core/orchestrator-logger';
import { OrchestratorStepParameters } from './options/orchestrator-step-parameters';
import { WorkflowCompositionRoot } from './workflows/workflow-composition-root';
import { OrchestratorError } from './error/orchestrator-error';
import { TaskParameterSerializer } from './services/core/task-parameter-serializer';
import * as core from '@actions/core';
import OrchestratorSecret from './options/orchestrator-secret';
import { ProviderInterface } from './providers/provider-interface';
import OrchestratorEnvironmentVariable from './options/orchestrator-environment-variable';
import TestOrchestrator from './providers/test';
import LocalOrchestrator from './providers/local';
import LocalDockerOrchestrator from './providers/docker';
import GcpCloudRunProvider from './providers/gcp-cloud-run';
import AzureAciProvider from './providers/azure-aci';
import RemotePowershellProvider from './providers/remote-powershell';
import GitHubActionsProvider from './providers/github-actions';
import GitLabCIProvider from './providers/gitlab-ci';
import AnsibleProvider from './providers/ansible';
import loadProvider from './providers/provider-loader';
import GitHub from '../github';
import SharedWorkspaceLocking from './services/core/shared-workspace-locking';
import { FollowLogStreamService } from './services/core/follow-log-stream-service';
import OrchestratorResult from './services/core/orchestrator-result';
import OrchestratorOptions from './options/orchestrator-options';
import ResourceTracking from './services/core/resource-tracking';
import { RunnerAvailabilityService } from './services/core/runner-availability-service';
class Orchestrator {
public static Provider: ProviderInterface;
public static buildParameters: BuildParameters;
private static defaultSecrets: OrchestratorSecret[];
private static orchestratorEnvironmentVariables: OrchestratorEnvironmentVariable[];
static lockedWorkspace: string = ``;
public static readonly retainedWorkspacePrefix: string = `retained-workspace`;
// When true, validates AWS CloudFormation templates even when using local-docker execution
// This is set by AWS_FORCE_PROVIDER=aws-local mode
public static validateAwsTemplates: boolean = false;
public static get isOrchestratorEnvironment() {
return process.env[`GITHUB_ACTIONS`] !== `true`;
}
public static get isOrchestratorAsyncEnvironment() {
return process.env[`ASYNC_WORKFLOW`] === `true`;
}
public static async setup(buildParameters: BuildParameters) {
OrchestratorLogger.setup();
OrchestratorLogger.log(`Setting up orchestrator`);
Orchestrator.buildParameters = buildParameters;
ResourceTracking.logAllocationSummary('setup');
await ResourceTracking.logDiskUsageSnapshot('setup');
if (Orchestrator.buildParameters.githubCheckId === ``) {
Orchestrator.buildParameters.githubCheckId = await GitHub.createGitHubCheck(
Orchestrator.buildParameters.buildGuid,
);
}
await Orchestrator.setupSelectedBuildPlatform();
Orchestrator.defaultSecrets = TaskParameterSerializer.readDefaultSecrets();
Orchestrator.orchestratorEnvironmentVariables =
TaskParameterSerializer.createOrchestratorEnvironmentVariables(buildParameters);
if (GitHub.githubInputEnabled) {
const buildParameterPropertyNames = Object.getOwnPropertyNames(buildParameters);
for (const element of Orchestrator.orchestratorEnvironmentVariables) {
// OrchestratorLogger.log(`Orchestrator output ${Input.ToEnvVarFormat(element.name)} = ${element.value}`);
core.setOutput(Input.ToEnvVarFormat(element.name), element.value);
}
for (const element of buildParameterPropertyNames) {
// OrchestratorLogger.log(`Orchestrator output ${Input.ToEnvVarFormat(element)} = ${buildParameters[element]}`);
core.setOutput(Input.ToEnvVarFormat(element), buildParameters[element]);
}
core.setOutput(
Input.ToEnvVarFormat(`buildArtifact`),
`build-${Orchestrator.buildParameters.buildGuid}.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
}`,
);
}
FollowLogStreamService.Reset();
}
private static async setupSelectedBuildPlatform() {
OrchestratorLogger.log(`Orchestrator platform selected ${Orchestrator.buildParameters.providerStrategy}`);
// Check runner availability and apply fallback if needed
if (Orchestrator.buildParameters.runnerCheckEnabled && Orchestrator.buildParameters.fallbackProviderStrategy) {
const owner = OrchestratorOptions.githubOwner;
const repo = OrchestratorOptions.githubRepoName;
const token = Orchestrator.buildParameters.gitPrivateToken || process.env.GITHUB_TOKEN || '';
OrchestratorLogger.log(
`Checking runner availability (labels: [${Orchestrator.buildParameters.runnerCheckLabels.join(', ')}], min: ${
Orchestrator.buildParameters.runnerCheckMinAvailable
})`,
);
const result = await RunnerAvailabilityService.checkAvailability(
owner,
repo,
token,
Orchestrator.buildParameters.runnerCheckLabels,
Orchestrator.buildParameters.runnerCheckMinAvailable,
);
OrchestratorLogger.log(
`Runner check: ${result.totalRunners} total, ${result.matchingRunners} matching, ${result.idleRunners} idle — ${result.reason}`,
);
if (result.shouldFallback) {
const original = Orchestrator.buildParameters.providerStrategy;
const fallback = Orchestrator.buildParameters.fallbackProviderStrategy;
OrchestratorLogger.log(`Falling back from '${original}' to '${fallback}' — ${result.reason}`);
Orchestrator.buildParameters.providerStrategy = fallback;
core.setOutput('providerFallbackUsed', 'true');
core.setOutput('providerFallbackReason', result.reason);
} else {
core.setOutput('providerFallbackUsed', 'false');
}
}
// Detect LocalStack endpoints and handle AWS provider appropriately
// AWS_FORCE_PROVIDER options:
// - 'aws': Force AWS provider (requires LocalStack Pro with ECS support)
// - 'aws-local': Validate AWS templates/config but execute via local-docker (for CI without ECS)
// - unset/other: Auto-fallback to local-docker when LocalStack detected
const awsForceProvider = process.env.AWS_FORCE_PROVIDER || '';
const forceAwsProvider = awsForceProvider === 'aws' || awsForceProvider === 'true';
const useAwsLocalMode = awsForceProvider === 'aws-local';
const endpointsToCheck = [
process.env.AWS_ENDPOINT,
process.env.AWS_S3_ENDPOINT,
process.env.AWS_CLOUD_FORMATION_ENDPOINT,
process.env.AWS_ECS_ENDPOINT,
process.env.AWS_KINESIS_ENDPOINT,
process.env.AWS_CLOUD_WATCH_LOGS_ENDPOINT,
OrchestratorOptions.awsEndpoint,
OrchestratorOptions.awsS3Endpoint,
OrchestratorOptions.awsCloudFormationEndpoint,
OrchestratorOptions.awsEcsEndpoint,
OrchestratorOptions.awsKinesisEndpoint,
OrchestratorOptions.awsCloudWatchLogsEndpoint,
]
.filter((x) => typeof x === 'string')
.join(' ');
const isLocalStack = /localstack|localhost|127\.0\.0\.1/i.test(endpointsToCheck);
let provider = Orchestrator.buildParameters.providerStrategy;
let validateAwsTemplates = false;
if (provider === 'aws' && isLocalStack) {
if (useAwsLocalMode) {
// aws-local mode: Validate AWS templates but execute via local-docker
// This provides confidence in AWS CloudFormation without requiring LocalStack Pro
OrchestratorLogger.log('AWS_FORCE_PROVIDER=aws-local: Validating AWS templates, executing via local-docker');
validateAwsTemplates = true;
provider = 'local-docker';
} else if (forceAwsProvider) {
// Force full AWS provider (requires LocalStack Pro with ECS support)
OrchestratorLogger.log(
'LocalStack endpoints detected but AWS_FORCE_PROVIDER=aws; using full AWS provider (requires ECS support)',
);
} else {
// Auto-fallback to local-docker
OrchestratorLogger.log('LocalStack endpoints detected; routing provider to local-docker for this run');
OrchestratorLogger.log(
'Note: Set AWS_FORCE_PROVIDER=aws-local to validate AWS templates with local-docker execution',
);
provider = 'local-docker';
}
}
// Store whether we should validate AWS templates (used by aws-local mode)
Orchestrator.validateAwsTemplates = validateAwsTemplates;
// Check for CLI provider executable
if (Orchestrator.buildParameters.providerExecutable) {
const { default: CliProvider } = await import('./providers/cli');
Orchestrator.Provider = new CliProvider(
Orchestrator.buildParameters.providerExecutable,
Orchestrator.buildParameters,
);
OrchestratorLogger.log(`Using CLI provider executable: ${Orchestrator.buildParameters.providerExecutable}`);
return;
}
switch (provider) {
case 'k8s':
Orchestrator.Provider = new Kubernetes(Orchestrator.buildParameters);
break;
case 'aws':
Orchestrator.Provider = new AwsBuildPlatform(Orchestrator.buildParameters);
// Validate that AWS provider is actually being used when expected
if (isLocalStack && forceAwsProvider) {
OrchestratorLogger.log('✓ AWS provider initialized with LocalStack - AWS functionality will be validated');
} else if (isLocalStack && !forceAwsProvider) {
OrchestratorLogger.log(
'⚠ WARNING: AWS provider was requested but LocalStack detected without AWS_FORCE_PROVIDER',
);
OrchestratorLogger.log('⚠ This may cause AWS functionality tests to fail validation');
}
break;
case 'test':
Orchestrator.Provider = new TestOrchestrator();
break;
case 'local-docker':
Orchestrator.Provider = new LocalDockerOrchestrator();
break;
case 'local-system':
Orchestrator.Provider = new LocalOrchestrator();
break;
case 'local':
Orchestrator.Provider = new LocalOrchestrator();
break;
case 'gcp-cloud-run':
OrchestratorLogger.log('⚠ EXPERIMENTAL: GCP Cloud Run Jobs provider');
Orchestrator.Provider = new GcpCloudRunProvider(Orchestrator.buildParameters);
break;
case 'azure-aci':
OrchestratorLogger.log('⚠ EXPERIMENTAL: Azure Container Instances provider');
Orchestrator.Provider = new AzureAciProvider(Orchestrator.buildParameters);
case 'remote-powershell':
Orchestrator.Provider = new RemotePowershellProvider(Orchestrator.buildParameters);
break;
case 'github-actions':
Orchestrator.Provider = new GitHubActionsProvider(Orchestrator.buildParameters);
break;
case 'gitlab-ci':
Orchestrator.Provider = new GitLabCIProvider(Orchestrator.buildParameters);
break;
case 'ansible':
Orchestrator.Provider = new AnsibleProvider(Orchestrator.buildParameters);
break;
default:
// Try to load provider using the dynamic loader for unknown providers
try {
Orchestrator.Provider = await loadProvider(provider, Orchestrator.buildParameters);
} catch (error: any) {
OrchestratorLogger.log(`Failed to load provider '${provider}' using dynamic loader: ${error.message}`);
OrchestratorLogger.log('Falling back to local provider...');
Orchestrator.Provider = new LocalOrchestrator();
}
break;
}
// Final validation: Ensure provider matches expectations
const finalProviderName = Orchestrator.Provider.constructor.name;
if (Orchestrator.buildParameters.providerStrategy === 'aws' && finalProviderName !== 'AWSBuildEnvironment') {
OrchestratorLogger.log(`⚠ WARNING: Expected AWS provider but got ${finalProviderName}`);
OrchestratorLogger.log('⚠ AWS functionality tests may not be validating AWS services correctly');
}
}
static async run(buildParameters: BuildParameters, baseImage: string) {
if (baseImage.includes(`undefined`)) {
throw new Error(`baseImage is undefined`);
}
try {
return await Orchestrator.runWithProvider(buildParameters, baseImage);
} catch (primaryError: any) {
// Retry on fallback provider if enabled and a fallback is configured
const fallback = buildParameters.fallbackProviderStrategy;
const alreadyOnFallback = buildParameters.providerStrategy === fallback;
if (buildParameters.retryOnFallback && fallback && !alreadyOnFallback) {
OrchestratorLogger.log(
`Primary provider '${buildParameters.providerStrategy}' failed: ${primaryError.message}`,
);
OrchestratorLogger.log(`Retrying build on fallback provider '${fallback}'...`);
buildParameters.providerStrategy = fallback;
core.setOutput('providerFallbackUsed', 'true');
core.setOutput('providerFallbackReason', `Primary provider failed: ${primaryError.message}`);
return await Orchestrator.runWithProvider(buildParameters, baseImage);
}
throw primaryError;
}
}
private static async runWithProvider(buildParameters: BuildParameters, baseImage: string) {
await Orchestrator.setup(buildParameters);
// When aws-local mode is enabled, validate AWS CloudFormation templates
// This ensures AWS templates are correct even when executing via local-docker
if (Orchestrator.validateAwsTemplates) {
await Orchestrator.validateAwsCloudFormationTemplates();
}
// Setup workflow with optional init timeout
await Orchestrator.setupWorkflowWithTimeout();
try {
if (buildParameters.maxRetainedWorkspaces > 0) {
Orchestrator.lockedWorkspace = SharedWorkspaceLocking.NewWorkspaceName();
const result = await SharedWorkspaceLocking.GetLockedWorkspace(
Orchestrator.lockedWorkspace,
Orchestrator.buildParameters.buildGuid,
Orchestrator.buildParameters,
);
if (result) {
OrchestratorLogger.logLine(`Using retained workspace ${Orchestrator.lockedWorkspace}`);
Orchestrator.orchestratorEnvironmentVariables = [
...Orchestrator.orchestratorEnvironmentVariables,
{ name: `LOCKED_WORKSPACE`, value: Orchestrator.lockedWorkspace },
];
} else {
OrchestratorLogger.log(`Max retained workspaces reached ${buildParameters.maxRetainedWorkspaces}`);
buildParameters.maxRetainedWorkspaces = 0;
Orchestrator.lockedWorkspace = ``;
}
}
await Orchestrator.updateStatusWithBuildParameters();
const output = await new WorkflowCompositionRoot().run(
new OrchestratorStepParameters(
baseImage,
Orchestrator.orchestratorEnvironmentVariables,
Orchestrator.defaultSecrets,
),
);
await Orchestrator.Provider.cleanupWorkflow(
Orchestrator.buildParameters,
Orchestrator.buildParameters.branch,
Orchestrator.defaultSecrets,
);
if (!Orchestrator.buildParameters.isCliMode) core.endGroup();
if (buildParameters.asyncWorkflow && this.isOrchestratorEnvironment && this.isOrchestratorAsyncEnvironment) {
await GitHub.updateGitHubCheck(Orchestrator.buildParameters.buildGuid, `success`, `success`, `completed`);
}
if (BuildParameters.shouldUseRetainedWorkspaceMode(buildParameters)) {
const workspace = Orchestrator.lockedWorkspace || ``;
await SharedWorkspaceLocking.ReleaseWorkspace(
workspace,
Orchestrator.buildParameters.buildGuid,
Orchestrator.buildParameters,
);
const isLocked = await SharedWorkspaceLocking.IsWorkspaceLocked(workspace, Orchestrator.buildParameters);
if (isLocked) {
throw new Error(
`still locked after releasing ${await SharedWorkspaceLocking.GetAllLocksForWorkspace(
workspace,
buildParameters,
)}`,
);
}
Orchestrator.lockedWorkspace = ``;
}
await GitHub.triggerWorkflowOnComplete(Orchestrator.buildParameters.finalHooks);
if (buildParameters.constantGarbageCollection) {
Orchestrator.Provider.garbageCollect(``, true, buildParameters.garbageMaxAge, true, true);
}
return new OrchestratorResult(buildParameters, output, true, true, false);
} catch (error: any) {
OrchestratorLogger.log(JSON.stringify(error, undefined, 4));
await GitHub.updateGitHubCheck(
Orchestrator.buildParameters.buildGuid,
`Failed - Error ${error?.message || error}`,
`failure`,
`completed`,
);
if (!Orchestrator.buildParameters.isCliMode) core.endGroup();
await OrchestratorError.handleException(error, Orchestrator.buildParameters, Orchestrator.defaultSecrets);
throw error;
}
}
/**
* Runs setupWorkflow with an optional timeout. If providerInitTimeout is set and the
* provider takes longer than that to initialize, throws an error that triggers
* retry-on-fallback (if enabled).
*/
private static async setupWorkflowWithTimeout() {
const timeoutSeconds = Orchestrator.buildParameters.providerInitTimeout;
const setupPromise = Orchestrator.Provider.setupWorkflow(
Orchestrator.buildParameters.buildGuid,
Orchestrator.buildParameters,
Orchestrator.buildParameters.branch,
Orchestrator.defaultSecrets,
);
if (timeoutSeconds <= 0) {
await setupPromise;
return;
}
OrchestratorLogger.log(`Provider init timeout: ${timeoutSeconds}s`);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error(`Provider initialization timed out after ${timeoutSeconds}s`)),
timeoutSeconds * 1000,
);
});
await Promise.race([setupPromise, timeoutPromise]);
}
private static async updateStatusWithBuildParameters() {
const content = { ...Orchestrator.buildParameters };
content.gitPrivateToken = ``;
content.unitySerial = ``;
content.unityEmail = ``;
content.unityPassword = ``;
const jsonContent = JSON.stringify(content, undefined, 4);
await GitHub.updateGitHubCheck(jsonContent, Orchestrator.buildParameters.buildGuid);
}
/**
* Validates AWS CloudFormation templates without deploying them.
* Used by aws-local mode to ensure AWS templates are correct when executing via local-docker.
* This provides confidence that AWS ECS deployments would work with the generated templates.
*/
private static async validateAwsCloudFormationTemplates() {
OrchestratorLogger.log('=== AWS CloudFormation Template Validation (aws-local mode) ===');
try {
// Import AWS template formations
const { BaseStackFormation } = await import('./providers/aws/cloud-formations/base-stack-formation');
const { TaskDefinitionFormation } = await import('./providers/aws/cloud-formations/task-definition-formation');
// Validate base stack template
const baseTemplate = BaseStackFormation.formation;
OrchestratorLogger.log(`✓ Base stack template generated (${baseTemplate.length} chars)`);
// Check for required resources in base stack
const requiredBaseResources = ['AWS::EC2::VPC', 'AWS::ECS::Cluster', 'AWS::S3::Bucket', 'AWS::IAM::Role'];
for (const resource of requiredBaseResources) {
if (baseTemplate.includes(resource)) {
OrchestratorLogger.log(` ✓ Contains ${resource}`);
} else {
throw new Error(`Base stack template missing required resource: ${resource}`);
}
}
// Validate task definition template
const taskTemplate = TaskDefinitionFormation.formation;
OrchestratorLogger.log(`✓ Task definition template generated (${taskTemplate.length} chars)`);
// Check for required resources in task definition
const requiredTaskResources = ['AWS::ECS::TaskDefinition', 'AWS::Logs::LogGroup'];
for (const resource of requiredTaskResources) {
if (taskTemplate.includes(resource)) {
OrchestratorLogger.log(` ✓ Contains ${resource}`);
} else {
throw new Error(`Task definition template missing required resource: ${resource}`);
}
}
// Validate YAML syntax by checking for common patterns
if (!baseTemplate.includes('AWSTemplateFormatVersion')) {
throw new Error('Base stack template missing AWSTemplateFormatVersion');
}
if (!taskTemplate.includes('AWSTemplateFormatVersion')) {
throw new Error('Task definition template missing AWSTemplateFormatVersion');
}
OrchestratorLogger.log('=== AWS CloudFormation templates validated successfully ===');
OrchestratorLogger.log('Note: Actual execution will use local-docker provider');
} catch (error: any) {
OrchestratorLogger.log(`AWS CloudFormation template validation failed: ${error.message}`);
throw error;
}
}
}
export default Orchestrator;

View File

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

View File

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

View File

@@ -0,0 +1,197 @@
import * as core from '@actions/core';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
/**
* Ansible provider — executes Unity builds via Ansible playbooks
* against managed inventory.
*
* Use case: Teams with existing Ansible infrastructure for server
* management who want to leverage their inventory for build distribution.
*/
class AnsibleProvider implements ProviderInterface {
private buildParameters: BuildParameters;
private inventory: string;
private playbook: string;
private extraVariables: string;
private vaultPassword: string;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.inventory = buildParameters.ansibleInventory || '';
this.playbook = buildParameters.ansiblePlaybook || '';
this.extraVariables = buildParameters.ansibleExtraVars || '';
this.vaultPassword = buildParameters.ansibleVaultPassword || '';
}
async setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[Ansible] Setting up playbook execution`);
if (!this.inventory) {
throw new Error('ansibleInventory is required for the ansible provider');
}
// Verify ansible is available
try {
const version = await OrchestratorSystem.Run('ansible --version | head -1');
OrchestratorLogger.log(`[Ansible] ${version.trim()}`);
} catch (error: any) {
throw new Error(`Ansible not found on PATH: ${error.message || error}`);
}
// Verify ansible-playbook binary exists (may be separate from ansible)
try {
await OrchestratorSystem.Run('command -v ansible-playbook || which ansible-playbook || where ansible-playbook');
OrchestratorLogger.log(`[Ansible] ansible-playbook binary verified`);
} catch (error: any) {
core.error('ansible-playbook not found. Install Ansible or ensure it is in PATH.');
throw new Error(`ansible-playbook not found on PATH: ${error.message || error}`);
}
// Verify inventory exists
try {
await OrchestratorSystem.Run(`test -e "${this.inventory}"`);
} catch {
throw new Error(`Inventory not found: ${this.inventory}`);
}
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[Ansible] Running playbook against inventory ${this.inventory}`);
if (!this.playbook) {
throw new Error(
'ansiblePlaybook is required — no default playbook is provided yet. ' +
'Provide a playbook that accepts build_guid, build_image, build_commands, mount_dir, and working_dir variables.',
);
}
// Build extra-vars JSON
// These use snake_case because they are Ansible variable names passed to playbooks
const playbookVariables: Record<string, string> = {
// eslint-disable-next-line camelcase
build_guid: buildGuid,
// eslint-disable-next-line camelcase
build_image: image,
// eslint-disable-next-line camelcase
build_commands: commands,
// eslint-disable-next-line camelcase
mount_dir: mountdir,
// eslint-disable-next-line camelcase
working_dir: workingdir,
};
for (const element of environment) {
playbookVariables[element.name.toLowerCase()] = element.value;
}
// Merge user-provided extra vars
if (this.extraVariables) {
try {
const userVariables = JSON.parse(this.extraVariables);
Object.assign(playbookVariables, userVariables);
} catch {
OrchestratorLogger.logWarning(`[Ansible] Failed to parse ansibleExtraVars as JSON, using as-is`);
}
}
const extraVariablesJson = JSON.stringify(playbookVariables).replace(/'/g, "'\\''");
// Build ansible-playbook command
const commandParts = [
'ansible-playbook',
`-i "${this.inventory}"`,
`"${this.playbook}"`,
`-e '${extraVariablesJson}'`,
'--no-color',
];
if (this.vaultPassword) {
commandParts.push(`--vault-password-file "${this.vaultPassword}"`);
}
// Add secret variables as extra environment
const environmentPrefix = secrets
.map((secret) => `${secret.EnvironmentVariable}='${secret.ParameterValue}'`)
.join(' ');
const fullCommand = environmentPrefix ? `${environmentPrefix} ${commandParts.join(' ')}` : commandParts.join(' ');
try {
const output = await OrchestratorSystem.Run(fullCommand);
OrchestratorLogger.log(`[Ansible] Playbook completed successfully`);
return output;
} catch (error: any) {
OrchestratorLogger.logWarning(`[Ansible] Playbook failed: ${error.message || error}`);
throw error;
}
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[Ansible] Cleanup complete`);
}
async garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
return '';
}
async listResources(): Promise<ProviderResource[]> {
if (!this.inventory) return [];
const resource = new ProviderResource();
resource.Name = this.inventory;
return [resource];
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
return [];
}
async watchWorkflow(): Promise<string> {
return '';
}
}
export default AnsibleProvider;

View File

@@ -0,0 +1,170 @@
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import * as core from '@actions/core';
import {
CloudFormation,
CreateStackCommand,
// eslint-disable-next-line import/named
CreateStackCommandInput,
DescribeStacksCommand,
// eslint-disable-next-line import/named
DescribeStacksCommandInput,
ListStacksCommand,
// eslint-disable-next-line import/named
Parameter,
UpdateStackCommand,
// eslint-disable-next-line import/named
UpdateStackCommandInput,
waitUntilStackCreateComplete,
waitUntilStackUpdateComplete,
} from '@aws-sdk/client-cloudformation';
import { BaseStackFormation } from './cloud-formations/base-stack-formation';
import crypto from 'node:crypto';
const DEFAULT_STACK_WAIT_TIME_SECONDS = 600;
function getStackWaitTime(): number {
const overrideValue = Number(process.env.ORCHESTRATOR_AWS_STACK_WAIT_TIME ?? '');
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}
export class AWSBaseStack {
constructor(baseStackName: string) {
this.baseStackName = baseStackName;
}
private baseStackName: string;
async setupBaseStack(CF: CloudFormation) {
const baseStackName = this.baseStackName;
const stackWaitTimeSeconds = getStackWaitTime();
const baseStack = BaseStackFormation.formation;
// Cloud Formation Input
const describeStackInput: DescribeStacksCommandInput = {
StackName: baseStackName,
};
const parametersWithoutHash: Parameter[] = [{ ParameterKey: 'EnvironmentName', ParameterValue: baseStackName }];
const parametersHash = crypto
.createHash('md5')
.update(baseStack + JSON.stringify(parametersWithoutHash))
.digest('hex');
const parameters: Parameter[] = [
...parametersWithoutHash,
...[{ ParameterKey: 'Version', ParameterValue: parametersHash }],
];
const updateInput: UpdateStackCommandInput = {
StackName: baseStackName,
TemplateBody: baseStack,
Parameters: parameters,
Capabilities: ['CAPABILITY_IAM'],
};
const createStackInput: CreateStackCommandInput = {
StackName: baseStackName,
TemplateBody: baseStack,
Parameters: parameters,
Capabilities: ['CAPABILITY_IAM'],
};
const stacks = await CF.send(
new ListStacksCommand({
StackStatusFilter: [
'CREATE_IN_PROGRESS',
'UPDATE_IN_PROGRESS',
'UPDATE_COMPLETE',
'CREATE_COMPLETE',
'ROLLBACK_COMPLETE',
],
}),
);
const stackNames = stacks.StackSummaries?.map((x) => x.StackName) || [];
const stackExists: boolean = stackNames.includes(baseStackName);
const describeStack = async () => {
return await CF.send(new DescribeStacksCommand(describeStackInput));
};
try {
if (!stackExists) {
OrchestratorLogger.log(`${baseStackName} stack does not exist (${JSON.stringify(stackNames)})`);
let created = false;
try {
await CF.send(new CreateStackCommand(createStackInput));
created = true;
} catch (error: any) {
const message = `${error?.name ?? ''} ${error?.message ?? ''}`;
if (message.includes('AlreadyExistsException')) {
OrchestratorLogger.log(`Base stack already exists, continuing with describe`);
} else {
throw error;
}
}
if (created) {
OrchestratorLogger.log(`created stack (version: ${parametersHash})`);
}
}
const CFState = await describeStack();
let stack = CFState.Stacks?.[0];
if (!stack) {
throw new Error(`Base stack doesn't exist, even after creation, stackExists check: ${stackExists}`);
}
const stackVersion = stack.Parameters?.find((x) => x.ParameterKey === 'Version')?.ParameterValue;
if (stack.StackStatus === 'CREATE_IN_PROGRESS') {
OrchestratorLogger.log(
`Waiting up to ${stackWaitTimeSeconds}s for '${baseStackName}' CloudFormation creation to finish`,
);
await waitUntilStackCreateComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
describeStackInput,
);
}
if (stackExists) {
OrchestratorLogger.log(`Base stack exists (version: ${stackVersion}, local version: ${parametersHash})`);
if (parametersHash !== stackVersion) {
OrchestratorLogger.log(`Attempting update of base stack`);
try {
await CF.send(new UpdateStackCommand(updateInput));
} catch (error: any) {
if (error['message'].includes('No updates are to be performed')) {
OrchestratorLogger.log(`No updates are to be performed`);
} else {
OrchestratorLogger.log(`Update Failed (Stack name: ${baseStackName})`);
OrchestratorLogger.log(error['message']);
}
OrchestratorLogger.log(`Continuing...`);
}
} else {
OrchestratorLogger.log(`No update required`);
}
stack = (await describeStack()).Stacks?.[0];
if (!stack) {
throw new Error(
`Base stack doesn't exist, even after updating and creation, stackExists check: ${stackExists}`,
);
}
if (stack.StackStatus === 'UPDATE_IN_PROGRESS') {
OrchestratorLogger.log(
`Waiting up to ${stackWaitTimeSeconds}s for '${baseStackName}' CloudFormation update to finish`,
);
await waitUntilStackUpdateComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
describeStackInput,
);
}
}
OrchestratorLogger.log('base stack is now ready');
} catch (error) {
core.error(JSON.stringify(await describeStack(), undefined, 4));
throw error;
}
}
}

View File

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

View File

@@ -0,0 +1,40 @@
import { TaskDefinitionFormation } from './cloud-formations/task-definition-formation';
export class AWSCloudFormationTemplates {
public static getParameterTemplate(p1: string) {
return `
${p1}:
Type: String
Default: ''
`;
}
public static getSecretTemplate(p1: string) {
return `
${p1}Secret:
Type: AWS::SecretsManager::Secret
Properties:
Name: '${p1}'
SecretString: !Ref ${p1}
`;
}
public static getSecretDefinitionTemplate(p1: string, p2: string) {
return `
Secrets:
- Name: '${p1}'
ValueFrom: !Ref ${p2}Secret
`;
}
public static insertAtTemplate(template: string, insertionKey: string, insertion: string) {
const index = template.search(insertionKey) + insertionKey.length + '\n'.length;
template = [template.slice(0, index), insertion, template.slice(index)].join('');
return template;
}
public static readTaskCloudFormationTemplate(): string {
return TaskDefinitionFormation.formation;
}
}

View File

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

View File

@@ -0,0 +1,242 @@
import {
CloudFormation,
CreateStackCommand,
// eslint-disable-next-line import/named
CreateStackCommandInput,
DescribeStackResourcesCommand,
DescribeStacksCommand,
ListStacksCommand,
waitUntilStackCreateComplete,
} from '@aws-sdk/client-cloudformation';
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { AWSCloudFormationTemplates } from './aws-cloud-formation-templates';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { AWSError } from './aws-error';
import Orchestrator from '../../orchestrator';
import { CleanupCronFormation } from './cloud-formations/cleanup-cron-formation';
import OrchestratorOptions from '../../options/orchestrator-options';
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 {
private baseStackName: string;
constructor(baseStackName: string) {
this.baseStackName = baseStackName;
}
public async setupCloudFormations(
CF: CloudFormation,
buildGuid: string,
image: string,
entrypoint: string[],
commands: string,
mountdir: string,
workingdir: string,
secrets: OrchestratorSecret[],
): Promise<OrchestratorAWSTaskDef> {
const taskDefStackName = `${this.baseStackName}-${buildGuid}`;
let taskDefCloudFormation = AWSCloudFormationTemplates.readTaskCloudFormationTemplate();
taskDefCloudFormation = taskDefCloudFormation.replace(
`ContainerCpu:
Default: 1024`,
`ContainerCpu:
Default: ${Number.parseInt(Orchestrator.buildParameters.containerCpu)}`,
);
taskDefCloudFormation = taskDefCloudFormation.replace(
`ContainerMemory:
Default: 2048`,
`ContainerMemory:
Default: ${Number.parseInt(Orchestrator.buildParameters.containerMemory)}`,
);
if (!OrchestratorOptions.asyncOrchestrator) {
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
taskDefCloudFormation,
'# template resources logstream',
TaskDefinitionFormation.streamLogs,
);
}
for (const secret of secrets) {
secret.ParameterKey = `${buildGuid.replace(/[^\dA-Za-z]/g, '')}${secret.ParameterKey.replace(
/[^\dA-Za-z]/g,
'',
)}`;
if (typeof secret.ParameterValue == 'number') {
secret.ParameterValue = `${secret.ParameterValue}`;
}
if (!secret.ParameterValue || secret.ParameterValue === '') {
secrets = secrets.filter((x) => x !== secret);
continue;
}
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
taskDefCloudFormation,
'p1 - input',
AWSCloudFormationTemplates.getParameterTemplate(secret.ParameterKey),
);
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
taskDefCloudFormation,
'# template resources secrets',
AWSCloudFormationTemplates.getSecretTemplate(`${secret.ParameterKey}`),
);
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
taskDefCloudFormation,
'p3 - container def',
AWSCloudFormationTemplates.getSecretDefinitionTemplate(secret.EnvironmentVariable, secret.ParameterKey),
);
}
const secretsMappedToCloudFormationParameters = secrets.map((x) => {
return { ParameterKey: x.ParameterKey.replace(/[^\dA-Za-z]/g, ''), ParameterValue: x.ParameterValue };
});
const logGroupName = `${this.baseStackName}/${taskDefStackName}`;
const parameters = [
{
ParameterKey: 'EnvironmentName',
ParameterValue: this.baseStackName,
},
{
ParameterKey: 'ImageUrl',
ParameterValue: image,
},
{
ParameterKey: 'ServiceName',
ParameterValue: taskDefStackName,
},
{
ParameterKey: 'LogGroupName',
ParameterValue: logGroupName,
},
{
ParameterKey: 'Command',
ParameterValue: 'echo "this template should be overwritten when running a task"',
},
{
ParameterKey: 'EntryPoint',
ParameterValue: entrypoint.join(','),
},
{
ParameterKey: 'WorkingDirectory',
ParameterValue: workingdir,
},
{
ParameterKey: 'EFSMountDirectory',
ParameterValue: mountdir,
},
...secretsMappedToCloudFormationParameters,
];
OrchestratorLogger.log(
`Starting AWS job with memory: ${Orchestrator.buildParameters.containerMemory} cpu: ${Orchestrator.buildParameters.containerCpu}`,
);
let previousStackExists = true;
while (previousStackExists) {
previousStackExists = false;
const stacks = await CF.send(new ListStacksCommand({}));
if (!stacks.StackSummaries) {
throw new Error('Faild to get stacks');
}
for (let index = 0; index < stacks.StackSummaries.length; index++) {
const element = stacks.StackSummaries[index];
if (element.StackName === taskDefStackName && element.StackStatus !== 'DELETE_COMPLETE') {
previousStackExists = true;
OrchestratorLogger.log(`Previous stack still exists: ${JSON.stringify(element)}`);
await new Promise((promise) => setTimeout(promise, 5000));
}
}
}
const createStackInput: CreateStackCommandInput = {
StackName: taskDefStackName,
TemplateBody: taskDefCloudFormation,
Capabilities: ['CAPABILITY_IAM'],
Parameters: parameters,
};
try {
const stackWaitTimeSeconds = getStackWaitTime();
OrchestratorLogger.log(
`Creating job aws formation ${taskDefStackName} (waiting up to ${stackWaitTimeSeconds}s for completion)`,
);
await CF.send(new CreateStackCommand(createStackInput));
await waitUntilStackCreateComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
{ StackName: taskDefStackName },
);
const describeStack = await CF.send(new DescribeStacksCommand({ StackName: taskDefStackName }));
for (const parameter of parameters) {
if (!describeStack.Stacks?.[0].Parameters?.some((x) => x.ParameterKey === parameter.ParameterKey)) {
throw new Error(`Parameter ${parameter.ParameterKey} not found in stack`);
}
}
} catch (error) {
await AWSError.handleStackCreationFailure(error, CF, taskDefStackName);
throw error;
}
const createCleanupStackInput: CreateStackCommandInput = {
StackName: `${taskDefStackName}-cleanup`,
TemplateBody: CleanupCronFormation.formation,
Capabilities: ['CAPABILITY_IAM'],
Parameters: [
{
ParameterKey: 'StackName',
ParameterValue: taskDefStackName,
},
{
ParameterKey: 'DeleteStackName',
ParameterValue: `${taskDefStackName}-cleanup`,
},
{
ParameterKey: 'TTL',
ParameterValue: `1080`,
},
{
ParameterKey: 'BUILDGUID',
ParameterValue: Orchestrator.buildParameters.buildGuid,
},
{
ParameterKey: 'EnvironmentName',
ParameterValue: this.baseStackName,
},
],
};
if (OrchestratorOptions.useCleanupCron) {
try {
OrchestratorLogger.log(`Creating job cleanup formation`);
await CF.send(new CreateStackCommand(createCleanupStackInput));
// await CF.waitFor('stackCreateComplete', { StackName: createCleanupStackInput.StackName }).promise();
} catch (error) {
await AWSError.handleStackCreationFailure(error, CF, taskDefStackName);
throw error;
}
}
const taskDefResources = (
await CF.send(
new DescribeStackResourcesCommand({
StackName: taskDefStackName,
}),
)
).StackResources;
const baseResources = (await CF.send(new DescribeStackResourcesCommand({ StackName: this.baseStackName })))
.StackResources;
return {
taskDefStackName,
taskDefCloudFormation,
taskDefResources,
baseResources,
};
}
}

View File

@@ -0,0 +1,335 @@
import { DescribeTasksCommand, RunTaskCommand, waitUntilTasksRunning } from '@aws-sdk/client-ecs';
import { DescribeStreamCommand, GetRecordsCommand, GetShardIteratorCommand } from '@aws-sdk/client-kinesis';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import * as core from '@actions/core';
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def';
import * as zlib from 'node:zlib';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { Input } from '../../..';
import Orchestrator from '../../orchestrator';
import { CommandHookService } from '../../services/hooks/command-hook-service';
import { FollowLogStreamService } from '../../services/core/follow-log-stream-service';
import OrchestratorOptions from '../../options/orchestrator-options';
import GitHub from '../../../github';
import { AwsClientFactory } from './aws-client-factory';
class AWSTaskRunner {
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(
taskDef: OrchestratorAWSTaskDef,
environment: OrchestratorEnvironmentVariable[],
commands: string,
): Promise<{ output: string; shouldCleanup: boolean }> {
const cluster = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ECSCluster')?.PhysicalResourceId || '';
const taskDefinition =
taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'TaskDefinition')?.PhysicalResourceId || '';
const SubnetOne =
taskDef.baseResources?.find((x) => x.LogicalResourceId === 'PublicSubnetOne')?.PhysicalResourceId || '';
const SubnetTwo =
taskDef.baseResources?.find((x) => x.LogicalResourceId === 'PublicSubnetTwo')?.PhysicalResourceId || '';
const ContainerSecurityGroup =
taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ContainerSecurityGroup')?.PhysicalResourceId || '';
const streamName =
taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'KinesisStream')?.PhysicalResourceId || '';
// Transform localhost endpoints for container environment
const transformedEnvironment = AWSTaskRunner.transformEndpointsForContainer(environment);
const runParameters = {
cluster,
taskDefinition,
platformVersion: '1.4.0',
overrides: {
containerOverrides: [
{
name: taskDef.taskDefStackName,
environment: transformedEnvironment,
command: ['-c', CommandHookService.ApplyHooksToCommands(commands, Orchestrator.buildParameters)],
},
],
},
launchType: 'FARGATE',
networkConfiguration: {
awsvpcConfiguration: {
subnets: [SubnetOne, SubnetTwo],
assignPublicIp: 'ENABLED',
securityGroups: [ContainerSecurityGroup],
},
},
};
if (JSON.stringify(runParameters.overrides.containerOverrides).length > 8192) {
OrchestratorLogger.log(JSON.stringify(runParameters.overrides.containerOverrides, undefined, 4));
throw new Error(`Container Overrides length must be at most 8192`);
}
const task = await AwsClientFactory.getECS().send(new RunTaskCommand(runParameters as any));
const taskArn = task.tasks?.[0].taskArn || '';
OrchestratorLogger.log('Orchestrator job is starting');
await AWSTaskRunner.waitUntilTaskRunning(taskArn, cluster);
OrchestratorLogger.log(
`Orchestrator job status is running ${(await AWSTaskRunner.describeTasks(cluster, taskArn))?.lastStatus} Async:${
OrchestratorOptions.asyncOrchestrator
}`,
);
if (OrchestratorOptions.asyncOrchestrator) {
const shouldCleanup: boolean = false;
const output: string = '';
OrchestratorLogger.log(`Watch Orchestrator To End: false`);
return { output, shouldCleanup };
}
OrchestratorLogger.log(`Streaming...`);
const { output, shouldCleanup } = await this.streamLogsUntilTaskStops(cluster, taskArn, streamName);
let exitCode;
let containerState;
let taskData;
while (exitCode === undefined) {
await new Promise((resolve) => setTimeout(resolve, 10000));
taskData = await AWSTaskRunner.describeTasks(cluster, taskArn);
const containers = taskData?.containers as any[] | undefined;
if (!containers || containers.length === 0) {
continue;
}
containerState = containers[0];
exitCode = containerState?.exitCode;
}
OrchestratorLogger.log(`Container State: ${JSON.stringify(containerState, undefined, 4)}`);
if (exitCode === undefined) {
OrchestratorLogger.logWarning(`Undefined exitcode for container`);
}
const wasSuccessful = exitCode === 0;
if (wasSuccessful) {
OrchestratorLogger.log(`Orchestrator job has finished successfully`);
return { output, shouldCleanup };
}
if (taskData?.stoppedReason === 'Essential container in task exited' && exitCode === 1) {
throw new Error('Container exited with code 1');
}
throw new Error(`Task failed`);
}
private static async waitUntilTaskRunning(taskArn: string, cluster: string) {
try {
await waitUntilTasksRunning(
{
client: AwsClientFactory.getECS(),
maxWaitTime: 300,
minDelay: 5,
maxDelay: 30,
},
{ tasks: [taskArn], cluster },
);
} catch (error_) {
const error = error_ as Error;
await new Promise((resolve) => setTimeout(resolve, 3000));
const taskAfterError = await AWSTaskRunner.describeTasks(cluster, taskArn);
OrchestratorLogger.log(`Orchestrator job has ended ${taskAfterError?.containers?.[0]?.lastStatus}`);
core.setFailed(error);
core.error(error);
}
}
static async describeTasks(clusterName: string, taskArn: string) {
const maxAttempts = 10;
let delayMs = 1000;
const maxDelayMs = 60000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const tasks = await AwsClientFactory.getECS().send(
new DescribeTasksCommand({ cluster: clusterName, tasks: [taskArn] }),
);
if (tasks.tasks?.[0]) {
return tasks.tasks?.[0];
}
throw new Error('No task found');
} catch (error: any) {
const isThrottle = error?.name === 'ThrottlingException' || /rate exceeded/i.test(String(error?.message));
if (!isThrottle || attempt === maxAttempts) {
throw error;
}
const jitterMs = Math.floor(Math.random() * Math.min(1000, delayMs));
const sleepMs = delayMs + jitterMs;
OrchestratorLogger.log(
`AWS throttled DescribeTasks (attempt ${attempt}/${maxAttempts}), backing off ${sleepMs}ms (${delayMs} + jitter ${jitterMs})`,
);
await new Promise((r) => setTimeout(r, sleepMs));
delayMs = Math.min(delayMs * 2, maxDelayMs);
}
}
}
static async streamLogsUntilTaskStops(clusterName: string, taskArn: string, kinesisStreamName: string) {
await new Promise((resolve) => setTimeout(resolve, 3000));
OrchestratorLogger.log(`Streaming...`);
const stream = await AWSTaskRunner.getLogStream(kinesisStreamName);
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}`;
OrchestratorLogger.log(`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 shouldCleanup = true;
let timestamp: number = 0;
let output = '';
while (shouldReadLogs) {
await new Promise((resolve) => setTimeout(resolve, 1500));
const taskData = await AWSTaskRunner.describeTasks(clusterName, taskArn);
({ timestamp, shouldReadLogs } = AWSTaskRunner.checkStreamingShouldContinue(taskData, timestamp, shouldReadLogs));
if (taskData?.lastStatus !== 'RUNNING') {
await new Promise((resolve) => setTimeout(resolve, 3500));
}
({ iterator, shouldReadLogs, output, shouldCleanup } = await AWSTaskRunner.handleLogStreamIteration(
iterator,
shouldReadLogs,
output,
shouldCleanup,
));
}
return { output, shouldCleanup };
}
private static async handleLogStreamIteration(
iterator: string,
shouldReadLogs: boolean,
output: string,
shouldCleanup: boolean,
) {
let records: any;
try {
records = await AwsClientFactory.getKinesis().send(new GetRecordsCommand({ ShardIterator: iterator }));
} catch (error: any) {
const isThrottle = error?.name === 'ThrottlingException' || /rate exceeded/i.test(String(error?.message));
if (isThrottle) {
const baseBackoffMs = 1000;
const jitterMs = Math.floor(Math.random() * 1000);
const sleepMs = baseBackoffMs + jitterMs;
OrchestratorLogger.log(`AWS throttled GetRecords, backing off ${sleepMs}ms (1000 + jitter ${jitterMs})`);
await new Promise((r) => setTimeout(r, sleepMs));
return { iterator, shouldReadLogs, output, shouldCleanup };
}
throw error;
}
iterator = records.NextShardIterator || '';
({ shouldReadLogs, output, shouldCleanup } = AWSTaskRunner.logRecords(
records,
iterator,
shouldReadLogs,
output,
shouldCleanup,
));
return { iterator, shouldReadLogs, output, shouldCleanup };
}
private static checkStreamingShouldContinue(taskData: any, timestamp: number, shouldReadLogs: boolean) {
if (taskData?.lastStatus === 'UNKNOWN') {
OrchestratorLogger.log('## Orchestrator job unknwon');
}
if (taskData?.lastStatus !== 'RUNNING') {
if (timestamp === 0) {
OrchestratorLogger.log('## Orchestrator job stopped, streaming end of logs');
timestamp = Date.now();
}
if (timestamp !== 0 && Date.now() - timestamp > 30000) {
OrchestratorLogger.log('## Orchestrator status is not RUNNING for 30 seconds, last query for logs');
shouldReadLogs = false;
}
OrchestratorLogger.log(`## Status of job: ${taskData.lastStatus}`);
}
return { timestamp, shouldReadLogs };
}
private static logRecords(
records: any,
iterator: string,
shouldReadLogs: boolean,
output: string,
shouldCleanup: boolean,
) {
if ((records.Records ?? []).length > 0 && iterator) {
for (const record of records.Records ?? []) {
const json = JSON.parse(
zlib.gunzipSync(Buffer.from(record.Data as unknown as string, 'base64')).toString('utf8'),
);
if (json.messageType === 'DATA_MESSAGE') {
for (const logEvent of json.logEvents) {
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
logEvent.message,
shouldReadLogs,
shouldCleanup,
output,
));
}
}
}
}
return { shouldReadLogs, output, shouldCleanup };
}
private static async getLogStream(kinesisStreamName: string) {
return await AwsClientFactory.getKinesis().send(new DescribeStreamCommand({ StreamName: kinesisStreamName }));
}
private static async getLogIterator(stream: any) {
return (
(
await AwsClientFactory.getKinesis().send(
new GetShardIteratorCommand({
ShardIteratorType: 'TRIM_HORIZON',
StreamName: stream.StreamDescription?.StreamName ?? '',
ShardId: stream.StreamDescription?.Shards?.[0]?.ShardId || '',
}),
)
).ShardIterator || ''
);
}
}
export default AWSTaskRunner;

View File

@@ -0,0 +1,397 @@
export class BaseStackFormation {
public static readonly baseStackDecription = `Game-CI base stack`;
public static readonly formation: string = `AWSTemplateFormatVersion: '2010-09-09'
Description: ${BaseStackFormation.baseStackDecription}
Parameters:
EnvironmentName:
Type: String
Default: development
Description: 'Your deployment environment: DEV, QA , PROD'
Version:
Type: String
Description: 'hash of template'
# ContainerPort:
# Type: Number
# Default: 80
# Description: What port number the application inside the docker container is binding to
Mappings:
# Hard values for the subnet masks. These masks define
# the range of internal IP addresses that can be assigned.
# The VPC can have all IP's from 10.0.0.0 to 10.0.255.255
# There are four subnets which cover the ranges:
#
# 10.0.0.0 - 10.0.0.255
# 10.0.1.0 - 10.0.1.255
# 10.0.2.0 - 10.0.2.255
# 10.0.3.0 - 10.0.3.255
SubnetConfig:
VPC:
CIDR: '10.0.0.0/16'
PublicOne:
CIDR: '10.0.0.0/24'
PublicTwo:
CIDR: '10.0.1.0/24'
Resources:
# VPC in which containers will be networked.
# It has two public subnets, and two private subnets.
# We distribute the subnets across the first two available subnets
# for the region, for high availability.
VPC:
Type: AWS::EC2::VPC
Properties:
EnableDnsSupport: true
EnableDnsHostnames: true
CidrBlock: !FindInMap ['SubnetConfig', 'VPC', 'CIDR']
MainBucket:
Type: "AWS::S3::Bucket"
Properties:
BucketName: !Ref EnvironmentName
EFSServerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName: 'efs-server-endpoints'
GroupDescription: Which client ip addrs are allowed to access EFS server
VpcId: !Ref 'VPC'
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 2049
ToPort: 2049
SourceSecurityGroupId: !Ref ContainerSecurityGroup
#CidrIp: !FindInMap ['SubnetConfig', 'VPC', 'CIDR']
# A security group for the containers we will run in Fargate.
# Rules are added to this security group based on what ingress you
# add for the cluster.
ContainerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName: 'task security group'
GroupDescription: Access to the Fargate containers
VpcId: !Ref 'VPC'
# SecurityGroupIngress:
# - IpProtocol: tcp
# FromPort: !Ref ContainerPort
# ToPort: !Ref ContainerPort
# CidrIp: 0.0.0.0/0
SecurityGroupEgress:
- IpProtocol: -1
FromPort: 2049
ToPort: 2049
CidrIp: '0.0.0.0/0'
# Two public subnets, where containers can have public IP addresses
PublicSubnetOne:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone: !Select
- 0
- Fn::GetAZs: !Ref 'AWS::Region'
VpcId: !Ref 'VPC'
CidrBlock: !FindInMap ['SubnetConfig', 'PublicOne', 'CIDR']
# MapPublicIpOnLaunch: true
PublicSubnetTwo:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone: !Select
- 1
- Fn::GetAZs: !Ref 'AWS::Region'
VpcId: !Ref 'VPC'
CidrBlock: !FindInMap ['SubnetConfig', 'PublicTwo', 'CIDR']
# MapPublicIpOnLaunch: true
# Setup networking resources for the public subnets. Containers
# in the public subnets have public IP addresses and the routing table
# sends network traffic via the internet gateway.
InternetGateway:
Type: AWS::EC2::InternetGateway
GatewayAttachement:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref 'VPC'
InternetGatewayId: !Ref 'InternetGateway'
# Attaching a Internet Gateway to route table makes it public.
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref 'VPC'
PublicRoute:
Type: AWS::EC2::Route
DependsOn: GatewayAttachement
Properties:
RouteTableId: !Ref 'PublicRouteTable'
DestinationCidrBlock: '0.0.0.0/0'
GatewayId: !Ref 'InternetGateway'
# Attaching a public route table makes a subnet public.
PublicSubnetOneRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnetOne
RouteTableId: !Ref PublicRouteTable
PublicSubnetTwoRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnetTwo
RouteTableId: !Ref PublicRouteTable
# ECS Resources
ECSCluster:
Type: AWS::ECS::Cluster
# A role used to allow AWS Autoscaling to inspect stats and adjust scaleable targets
# on your AWS account
AutoscalingRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: [application-autoscaling.amazonaws.com]
Action: ['sts:AssumeRole']
Path: /
Policies:
- PolicyName: service-autoscaling
PolicyDocument:
Statement:
- Effect: Allow
Action:
- 'application-autoscaling:*'
- 'cloudwatch:DescribeAlarms'
- 'cloudwatch:PutMetricAlarm'
- 'ecs:DescribeServices'
- 'ecs:UpdateService'
Resource: '*'
# This is an IAM role which authorizes ECS to manage resources on your
# account on your behalf, such as updating your load balancer with the
# details of where your containers are, so that traffic can reach your
# containers.
ECSRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: [ecs.amazonaws.com]
Action: ['sts:AssumeRole']
Path: /
Policies:
- PolicyName: ecs-service
PolicyDocument:
Statement:
- Effect: Allow
Action:
# Rules which allow ECS to attach network interfaces to instances
# on your behalf in order for awsvpc networking mode to work right
- 'ec2:AttachNetworkInterface'
- 'ec2:CreateNetworkInterface'
- 'ec2:CreateNetworkInterfacePermission'
- 'ec2:DeleteNetworkInterface'
- 'ec2:DeleteNetworkInterfacePermission'
- 'ec2:Describe*'
- 'ec2:DetachNetworkInterface'
# Rules which allow ECS to update load balancers on your behalf
# with the information sabout how to send traffic to your containers
- 'elasticloadbalancing:DeregisterInstancesFromLoadBalancer'
- 'elasticloadbalancing:DeregisterTargets'
- 'elasticloadbalancing:Describe*'
- 'elasticloadbalancing:RegisterInstancesWithLoadBalancer'
- 'elasticloadbalancing:RegisterTargets'
Resource: '*'
# This is a role which is used by the ECS tasks themselves.
ECSTaskExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: [ecs-tasks.amazonaws.com]
Action: ['sts:AssumeRole']
Path: /
Policies:
- PolicyName: AmazonECSTaskExecutionRolePolicy
PolicyDocument:
Statement:
- Effect: Allow
Action:
# Allow the use of secret manager
- 'secretsmanager:GetSecretValue'
- 'kms:Decrypt'
# Allow the ECS Tasks to download images from ECR
- 'ecr:GetAuthorizationToken'
- 'ecr:BatchCheckLayerAvailability'
- 'ecr:GetDownloadUrlForLayer'
- 'ecr:BatchGetImage'
# Allow the ECS tasks to upload logs to CloudWatch
- 'logs:CreateLogStream'
- 'logs:PutLogEvents'
Resource: '*'
DeleteCFNLambdaExecutionRole:
Type: 'AWS::IAM::Role'
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: 'Allow'
Principal:
Service: ['lambda.amazonaws.com']
Action: 'sts:AssumeRole'
Path: '/'
Policies:
- PolicyName: DeleteCFNLambdaExecutionRole
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: 'Allow'
Action:
- 'logs:CreateLogGroup'
- 'logs:CreateLogStream'
- 'logs:PutLogEvents'
Resource: 'arn:aws:logs:*:*:*'
- Effect: 'Allow'
Action:
- 'cloudformation:DeleteStack'
- 'kinesis:DeleteStream'
- 'secretsmanager:DeleteSecret'
- 'kinesis:DescribeStreamSummary'
- 'logs:DeleteLogGroup'
- 'logs:DeleteSubscriptionFilter'
- 'ecs:DeregisterTaskDefinition'
- 'lambda:DeleteFunction'
- 'lambda:InvokeFunction'
- 'events:RemoveTargets'
- 'events:DeleteRule'
- 'lambda:RemovePermission'
Resource: '*'
### cloud watch to kinesis role
CloudWatchIAMRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: [logs.amazonaws.com]
Action: ['sts:AssumeRole']
Path: /
Policies:
- PolicyName: service-autoscaling
PolicyDocument:
Statement:
- Effect: Allow
Action:
- 'kinesis:PutRecord'
Resource: '*'
#####################EFS#####################
EfsFileStorage:
Type: 'AWS::EFS::FileSystem'
Properties:
BackupPolicy:
Status: ENABLED
PerformanceMode: maxIO
Encrypted: false
FileSystemPolicy:
Version: '2012-10-17'
Statement:
- Effect: 'Allow'
Action:
- 'elasticfilesystem:ClientMount'
- 'elasticfilesystem:ClientWrite'
- 'elasticfilesystem:ClientRootAccess'
Principal:
AWS: '*'
MountTargetResource1:
Type: AWS::EFS::MountTarget
Properties:
FileSystemId: !Ref EfsFileStorage
SubnetId: !Ref PublicSubnetOne
SecurityGroups:
- !Ref EFSServerSecurityGroup
MountTargetResource2:
Type: AWS::EFS::MountTarget
Properties:
FileSystemId: !Ref EfsFileStorage
SubnetId: !Ref PublicSubnetTwo
SecurityGroups:
- !Ref EFSServerSecurityGroup
Outputs:
EfsFileStorageId:
Description: 'The connection endpoint for the database.'
Value: !Ref EfsFileStorage
Export:
Name: !Sub ${'${EnvironmentName}'}:EfsFileStorageId
ClusterName:
Description: The name of the ECS cluster
Value: !Ref 'ECSCluster'
Export:
Name: !Sub${' ${EnvironmentName}'}:ClusterName
AutoscalingRole:
Description: The ARN of the role used for autoscaling
Value: !GetAtt 'AutoscalingRole.Arn'
Export:
Name: !Sub ${'${EnvironmentName}'}:AutoscalingRole
ECSRole:
Description: The ARN of the ECS role
Value: !GetAtt 'ECSRole.Arn'
Export:
Name: !Sub ${'${EnvironmentName}'}:ECSRole
ECSTaskExecutionRole:
Description: The ARN of the ECS role tsk execution role
Value: !GetAtt 'ECSTaskExecutionRole.Arn'
Export:
Name: !Sub ${'${EnvironmentName}'}:ECSTaskExecutionRole
DeleteCFNLambdaExecutionRole:
Description: Lambda execution role for cleaning up cloud formations
Value: !GetAtt 'DeleteCFNLambdaExecutionRole.Arn'
Export:
Name: !Sub ${'${EnvironmentName}'}:DeleteCFNLambdaExecutionRole
CloudWatchIAMRole:
Description: The ARN of the CloudWatch role for subscription filter
Value: !GetAtt 'CloudWatchIAMRole.Arn'
Export:
Name: !Sub ${'${EnvironmentName}'}:CloudWatchIAMRole
VpcId:
Description: The ID of the VPC that this stack is deployed in
Value: !Ref 'VPC'
Export:
Name: !Sub ${'${EnvironmentName}'}:VpcId
PublicSubnetOne:
Description: Public subnet one
Value: !Ref 'PublicSubnetOne'
Export:
Name: !Sub ${'${EnvironmentName}'}:PublicSubnetOne
PublicSubnetTwo:
Description: Public subnet two
Value: !Ref 'PublicSubnetTwo'
Export:
Name: !Sub ${'${EnvironmentName}'}:PublicSubnetTwo
ContainerSecurityGroup:
Description: A security group used to allow Fargate containers to receive traffic
Value: !Ref 'ContainerSecurityGroup'
Export:
Name: !Sub ${'${EnvironmentName}'}:ContainerSecurityGroup
`;
}

View File

@@ -0,0 +1,146 @@
export class CleanupCronFormation {
public static readonly formation: string = `AWSTemplateFormatVersion: '2010-09-09'
Description: Schedule automatic deletion of CloudFormation stacks
Metadata:
AWS::CloudFormation::Interface:
ParameterGroups:
- Label:
default: Input configuration
Parameters:
- StackName
- TTL
ParameterLabels:
StackName:
default: Stack name
TTL:
default: Time-to-live
Parameters:
EnvironmentName:
Type: String
Default: development
Description: 'Your deployment environment: DEV, QA , PROD'
BUILDGUID:
Type: String
Default: ''
StackName:
Type: String
Description: Stack name that will be deleted.
DeleteStackName:
Type: String
Description: Stack name that will be deleted.
TTL:
Type: Number
Description: Time-to-live in minutes for the stack.
Resources:
DeleteCFNLambda:
Type: "AWS::Lambda::Function"
Properties:
FunctionName: !Join [ "", [ 'DeleteCFNLambda', !Ref BUILDGUID ] ]
Code:
ZipFile: |
import boto3
import os
import json
stack_name = os.environ['stackName']
delete_stack_name = os.environ['deleteStackName']
def delete_cfn(stack_name):
try:
cfn = boto3.resource('cloudformation')
stack = cfn.Stack(stack_name)
stack.delete()
return "SUCCESS"
except:
return "ERROR"
def handler(event, context):
print("Received event:")
print(json.dumps(event))
result = delete_cfn(stack_name)
delete_cfn(delete_stack_name)
return result
Environment:
Variables:
stackName: !Ref 'StackName'
deleteStackName: !Ref 'DeleteStackName'
Handler: "index.handler"
Runtime: "python3.9"
Timeout: "5"
Role:
'Fn::ImportValue': !Sub '\${EnvironmentName}:DeleteCFNLambdaExecutionRole'
DeleteStackEventRule:
DependsOn:
- DeleteCFNLambda
- GenerateCronExpression
Type: "AWS::Events::Rule"
Properties:
Name: !Join [ "", [ 'DeleteStackEventRule', !Ref BUILDGUID ] ]
Description: Delete stack event
ScheduleExpression: !GetAtt GenerateCronExpression.cron_exp
State: "ENABLED"
Targets:
-
Arn: !GetAtt DeleteCFNLambda.Arn
Id: 'DeleteCFNLambda'
PermissionForDeleteCFNLambda:
Type: "AWS::Lambda::Permission"
DependsOn:
- DeleteStackEventRule
Properties:
FunctionName: !Join [ "", [ 'DeleteCFNLambda', !Ref BUILDGUID ] ]
Action: "lambda:InvokeFunction"
Principal: "events.amazonaws.com"
SourceArn: !GetAtt DeleteStackEventRule.Arn
GenerateCronExpLambda:
Type: "AWS::Lambda::Function"
Properties:
FunctionName: !Join [ "", [ 'GenerateCronExpressionLambda', !Ref BUILDGUID ] ]
Code:
ZipFile: |
from datetime import datetime, timedelta
import os
import logging
import json
import cfnresponse
def deletion_time(ttl):
delete_at_time = datetime.now() + timedelta(minutes=int(ttl))
hh = delete_at_time.hour
mm = delete_at_time.minute
yyyy = delete_at_time.year
month = delete_at_time.month
dd = delete_at_time.day
# minutes hours day month day-of-week year
cron_exp = "cron({} {} {} {} ? {})".format(mm, hh, dd, month, yyyy)
return cron_exp
def handler(event, context):
print('Received event: %s' % json.dumps(event))
status = cfnresponse.SUCCESS
try:
if event['RequestType'] == 'Delete':
cfnresponse.send(event, context, status, {})
else:
ttl = event['ResourceProperties']['ttl']
responseData = {}
responseData['cron_exp'] = deletion_time(ttl)
cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData)
except Exception as e:
logging.error('Exception: %s' % e, exc_info=True)
status = cfnresponse.FAILED
cfnresponse.send(event, context, status, {}, None)
Handler: "index.handler"
Runtime: "python3.9"
Timeout: "5"
Role:
'Fn::ImportValue': !Sub '\${EnvironmentName}:DeleteCFNLambdaExecutionRole'
GenerateCronExpression:
Type: "Custom::GenerateCronExpression"
Version: "1.0"
Properties:
Name: !Join [ "", [ 'GenerateCronExpression', !Ref BUILDGUID ] ]
ServiceToken: !GetAtt GenerateCronExpLambda.Arn
ttl: !Ref 'TTL'
`;
}

View File

@@ -0,0 +1,168 @@
import Orchestrator from '../../../orchestrator';
export class TaskDefinitionFormation {
public static readonly description: string = `Game CI Orchestrator Task Stack`;
public static get formation(): string {
return `AWSTemplateFormatVersion: 2010-09-09
Description: ${TaskDefinitionFormation.description}
Parameters:
EnvironmentName:
Type: String
Default: development
Description: 'Your deployment environment: DEV, QA , PROD'
ServiceName:
Type: String
Default: example
Description: A name for the service
LogGroupName:
Type: String
Default: example
Description: Name to use for the log group created for this task
ImageUrl:
Type: String
Default: nginx
Description: >-
The url of a docker image that contains the application process that will
handle the traffic for this service
ContainerPort:
Type: Number
Default: 80
Description: What port number the application inside the docker container is binding to
ContainerCpu:
Default: ${Orchestrator.buildParameters.containerCpu}
Type: Number
Description: How much CPU to give the container. 1024 is 1 CPU
ContainerMemory:
Default: ${Orchestrator.buildParameters.containerMemory}
Type: Number
Description: How much memory in megabytes to give the container
BUILDGUID:
Type: String
Default: ''
Command:
Type: String
Default: 'ls'
EntryPoint:
Type: String
Default: '/bin/sh'
WorkingDirectory:
Type: String
Default: '/efsdata/'
Role:
Type: String
Default: ''
Description: >-
(Optional) An IAM role to give the service's containers if the code within
needs to access other AWS resources like S3 buckets, DynamoDB tables, etc
EFSMountDirectory:
Type: String
Default: '/efsdata'
# template secrets p1 - input
Mappings:
SubnetConfig:
VPC:
CIDR: 10.0.0.0/16
PublicOne:
CIDR: 10.0.0.0/24
PublicTwo:
CIDR: 10.0.1.0/24
Conditions:
HasCustomRole: !Not
- !Equals
- Ref: Role
- ''
Resources:
LogGroup:
Type: 'AWS::Logs::LogGroup'
Properties:
LogGroupName: !Ref LogGroupName
Metadata:
'AWS::CloudFormation::Designer':
id: aece53ae-b82d-4267-bc16-ed964b05db27
# template resources secrets
# template resources logstream
TaskDefinition:
Type: 'AWS::ECS::TaskDefinition'
Properties:
Family: !Ref ServiceName
Cpu: !Ref ContainerCpu
Memory: !Ref ContainerMemory
NetworkMode: awsvpc
Volumes:
- Name: efs-data
EFSVolumeConfiguration:
FilesystemId:
'Fn::ImportValue': !Sub '${'${EnvironmentName}'}:EfsFileStorageId'
TransitEncryption: DISABLED
RequiresCompatibilities:
- FARGATE
ExecutionRoleArn:
'Fn::ImportValue': !Sub '${'${EnvironmentName}'}:ECSTaskExecutionRole'
TaskRoleArn:
'Fn::If':
- HasCustomRole
- !Ref Role
- !Ref 'AWS::NoValue'
ContainerDefinitions:
- Name: !Ref ServiceName
Cpu: !Ref ContainerCpu
Memory: !Ref ContainerMemory
Image: !Ref ImageUrl
EntryPoint:
Fn::Split:
- ','
- !Ref EntryPoint
Command:
Fn::Split:
- ','
- !Ref Command
WorkingDirectory: !Ref WorkingDirectory
Environment:
- Name: ALLOW_EMPTY_PASSWORD
Value: 'yes'
# template - env vars
MountPoints:
- SourceVolume: efs-data
ContainerPath: !Ref EFSMountDirectory
ReadOnly: false
# template secrets p3 - container def
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref LogGroupName
awslogs-region: !Ref 'AWS::Region'
awslogs-stream-prefix: !Ref ServiceName
DependsOn:
- LogGroup
`;
}
public static streamLogs = `
SubscriptionFilter:
Type: 'AWS::Logs::SubscriptionFilter'
Properties:
FilterPattern: ''
RoleArn:
'Fn::ImportValue': !Sub '${'${EnvironmentName}'}:CloudWatchIAMRole'
LogGroupName: !Ref LogGroupName
DestinationArn:
'Fn::GetAtt':
- KinesisStream
- Arn
Metadata:
'AWS::CloudFormation::Designer':
id: 7f809e91-9e5d-4678-98c1-c5085956c480
DependsOn:
- LogGroup
- KinesisStream
KinesisStream:
Type: 'AWS::Kinesis::Stream'
Properties:
Name: !Ref ServiceName
ShardCount: 1
Metadata:
'AWS::CloudFormation::Designer':
id: c6f18447-b879-4696-8873-f981b2cedd2b
`;
}

View File

@@ -0,0 +1,176 @@
import { CloudFormation, DeleteStackCommand, waitUntilStackDeleteComplete } from '@aws-sdk/client-cloudformation';
import OrchestratorSecret from '../../options/orchestrator-secret';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def';
import AwsTaskRunner from './aws-task-runner';
import { ProviderInterface } from '../provider-interface';
import BuildParameters from '../../../build-parameters';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { AWSJobStack as AwsJobStack } from './aws-job-stack';
import { AWSBaseStack as AwsBaseStack } from './aws-base-stack';
import { Input } from '../../..';
import { GarbageCollectionService } from './services/garbage-collection-service';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { TaskService } from './services/task-service';
import OrchestratorOptions from '../../options/orchestrator-options';
import { AwsClientFactory } from './aws-client-factory';
import ResourceTracking from '../../services/core/resource-tracking';
const DEFAULT_STACK_WAIT_TIME_SECONDS = 600;
function getStackWaitTime(): number {
const overrideValue = Number(process.env.ORCHESTRATOR_AWS_STACK_WAIT_TIME ?? '');
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}
class AWSBuildEnvironment implements ProviderInterface {
private baseStackName: string;
constructor(buildParameters: BuildParameters) {
this.baseStackName = buildParameters.awsStackName;
}
async listResources(): Promise<ProviderResource[]> {
await TaskService.getCloudFormationJobStacks();
await TaskService.getLogGroups();
await TaskService.getTasks();
return [];
}
listWorkflow(): Promise<ProviderWorkflow[]> {
throw new Error('Method not implemented.');
}
async watchWorkflow(): Promise<string> {
return await TaskService.watch();
}
async listOtherResources(): Promise<string> {
await TaskService.getLogGroups();
return '';
}
async garbageCollect(
filter: string,
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
await GarbageCollectionService.cleanup(!previewOnly);
return ``;
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {}
async setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
process.env.AWS_REGION = Input.region;
const CF = AwsClientFactory.getCloudFormation();
await new AwsBaseStack(this.baseStackName).setupBaseStack(CF);
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
process.env.AWS_REGION = Input.region;
ResourceTracking.logAllocationSummary('aws workflow');
await ResourceTracking.logDiskUsageSnapshot('aws workflow (host)');
AwsClientFactory.getECS();
const CF = AwsClientFactory.getCloudFormation();
AwsClientFactory.getKinesis();
OrchestratorLogger.log(`AWS Region: ${CF.config.region}`);
const entrypoint = ['/bin/sh'];
const startTimeMs = Date.now();
const taskDef = await new AwsJobStack(this.baseStackName).setupCloudFormations(
CF,
buildGuid,
image,
entrypoint,
commands,
mountdir,
workingdir,
secrets,
);
let postRunTaskTimeMs;
try {
const postSetupStacksTimeMs = Date.now();
OrchestratorLogger.log(`Setup job time: ${Math.floor((postSetupStacksTimeMs - startTimeMs) / 1000)}s`);
const { output, shouldCleanup } = await AwsTaskRunner.runTask(taskDef, environment, commands);
postRunTaskTimeMs = Date.now();
OrchestratorLogger.log(`Run job time: ${Math.floor((postRunTaskTimeMs - postSetupStacksTimeMs) / 1000)}s`);
if (shouldCleanup) {
await this.cleanupResources(CF, taskDef);
}
const postCleanupTimeMs = Date.now();
if (postRunTaskTimeMs !== undefined)
OrchestratorLogger.log(`Cleanup job time: ${Math.floor((postCleanupTimeMs - postRunTaskTimeMs) / 1000)}s`);
return output;
} catch (error) {
OrchestratorLogger.log(`error running task ${error}`);
await this.cleanupResources(CF, taskDef);
throw error;
}
}
async cleanupResources(CF: CloudFormation, taskDef: OrchestratorAWSTaskDef) {
const stackWaitTimeSeconds = getStackWaitTime();
OrchestratorLogger.log(`Cleanup starting (waiting up to ${stackWaitTimeSeconds}s for stack deletion)`);
await CF.send(new DeleteStackCommand({ StackName: taskDef.taskDefStackName }));
if (OrchestratorOptions.useCleanupCron) {
await CF.send(new DeleteStackCommand({ StackName: `${taskDef.taskDefStackName}-cleanup` }));
}
await waitUntilStackDeleteComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
{
StackName: taskDef.taskDefStackName,
},
);
await waitUntilStackDeleteComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
{
StackName: `${taskDef.taskDefStackName}-cleanup`,
},
);
OrchestratorLogger.log(`Deleted Stack: ${taskDef.taskDefStackName}`);
OrchestratorLogger.log('Cleanup complete');
}
}
export default AWSBuildEnvironment;

View File

@@ -0,0 +1,10 @@
// eslint-disable-next-line import/named
import { StackResource } from '@aws-sdk/client-cloudformation';
class OrchestratorAWSTaskDef {
public taskDefStackName!: string;
public taskDefCloudFormation!: string;
public taskDefResources: StackResource[] | undefined;
public baseResources: StackResource[] | undefined;
}
export default OrchestratorAWSTaskDef;

View File

@@ -0,0 +1,75 @@
import { DeleteStackCommand, DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation';
import { DeleteLogGroupCommand } from '@aws-sdk/client-cloudwatch-logs';
import { StopTaskCommand } from '@aws-sdk/client-ecs';
import Input from '../../../../input';
import OrchestratorLogger from '../../../services/core/orchestrator-logger';
import { TaskService } from './task-service';
import { AwsClientFactory } from '../aws-client-factory';
export class GarbageCollectionService {
static isOlderThan1day(date: Date) {
const ageDate = new Date(date.getTime() - Date.now());
return ageDate.getDay() > 0;
}
public static async cleanup(deleteResources = false, OneDayOlderOnly: boolean = false) {
process.env.AWS_REGION = Input.region;
const CF = AwsClientFactory.getCloudFormation();
const ecs = AwsClientFactory.getECS();
const cwl = AwsClientFactory.getCloudWatchLogs();
const taskDefinitionsInUse = new Array();
const tasks = await TaskService.getTasks();
for (const task of tasks) {
const { taskElement, element } = task;
taskDefinitionsInUse.push(taskElement.taskDefinitionArn);
if (deleteResources && (!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(taskElement.createdAt!))) {
OrchestratorLogger.log(`Stopping task ${taskElement.containers?.[0].name}`);
await ecs.send(new StopTaskCommand({ task: taskElement.taskArn || '', cluster: element }));
}
}
const jobStacks = await TaskService.getCloudFormationJobStacks();
for (const element of jobStacks) {
if (
(await CF.send(new DescribeStackResourcesCommand({ StackName: element.StackName }))).StackResources?.some(
(x) => x.ResourceType === 'AWS::ECS::TaskDefinition' && taskDefinitionsInUse.includes(x.PhysicalResourceId),
)
) {
OrchestratorLogger.log(`Skipping ${element.StackName} - active task was running not deleting`);
return;
}
if (
deleteResources &&
(!OneDayOlderOnly || (element.CreationTime && GarbageCollectionService.isOlderThan1day(element.CreationTime)))
) {
if (element.StackName === 'game-ci' || element.TemplateDescription === 'Game-CI base stack') {
OrchestratorLogger.log(`Skipping ${element.StackName} ignore list`);
return;
}
OrchestratorLogger.log(`Deleting ${element.StackName}`);
await CF.send(new DeleteStackCommand({ StackName: element.StackName }));
}
}
const logGroups = await TaskService.getLogGroups();
for (const element of logGroups) {
if (
deleteResources &&
(!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(new Date(element.creationTime!)))
) {
OrchestratorLogger.log(`Deleting ${element.logGroupName}`);
await cwl.send(new DeleteLogGroupCommand({ logGroupName: element.logGroupName || '' }));
}
}
const locks = await TaskService.getLocks();
for (const element of locks) {
OrchestratorLogger.log(`Lock: ${element.Key}`);
}
}
}

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