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>
This commit is contained in:
frostebite
2026-03-05 11:16:36 +00:00
parent 9d475434d3
commit 12f287168d
10 changed files with 1807 additions and 3 deletions

View File

@@ -182,8 +182,8 @@ inputs:
required: false
default: ''
description:
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with the
keys image, secrets (name, value object array), command line string)'
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with
the keys image, secrets (name, value object array), command line string)'
awsStackName:
default: 'game-ci'
required: false
@@ -279,6 +279,66 @@ inputs:
description:
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
fixes.'
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:

781
dist/index.js generated vendored
View File

@@ -375,6 +375,25 @@ class BuildParameters {
cacheUnityInstallationOnMac: input_1.default.cacheUnityInstallationOnMac,
unityHubVersionOnMac: input_1.default.unityHubVersionOnMac,
dockerWorkspacePath: input_1.default.dockerWorkspacePath,
// Remote PowerShell provider
remotePowershellHost: input_1.default.remotePowershellHost,
remotePowershellCredential: input_1.default.remotePowershellCredential,
remotePowershellTransport: input_1.default.remotePowershellTransport,
// GitHub Actions provider
githubActionsRepo: input_1.default.githubActionsRepo,
githubActionsWorkflow: input_1.default.githubActionsWorkflow,
githubActionsToken: input_1.default.githubActionsToken,
githubActionsRef: input_1.default.githubActionsRef,
// GitLab CI provider
gitlabProjectId: input_1.default.gitlabProjectId,
gitlabTriggerToken: input_1.default.gitlabTriggerToken,
gitlabApiUrl: input_1.default.gitlabApiUrl,
gitlabRef: input_1.default.gitlabRef,
// Ansible provider
ansibleInventory: input_1.default.ansibleInventory,
ansiblePlaybook: input_1.default.ansiblePlaybook,
ansibleExtraVars: input_1.default.ansibleExtraVars,
ansibleVaultPassword: input_1.default.ansibleVaultPassword,
};
}
static parseBuildFile(filename, platform, androidExportType) {
@@ -1826,6 +1845,63 @@ class Input {
static get skipActivation() {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
// ### ### ###
// Remote PowerShell provider
// ### ### ###
static get remotePowershellHost() {
return Input.getInput('remotePowershellHost') ?? '';
}
static get remotePowershellCredential() {
return Input.getInput('remotePowershellCredential') ?? '';
}
static get remotePowershellTransport() {
return Input.getInput('remotePowershellTransport') ?? 'wsman';
}
// ### ### ###
// GitHub Actions provider
// ### ### ###
static get githubActionsRepo() {
return Input.getInput('githubActionsRepo') ?? '';
}
static get githubActionsWorkflow() {
return Input.getInput('githubActionsWorkflow') ?? '';
}
static get githubActionsToken() {
return Input.getInput('githubActionsToken') ?? '';
}
static get githubActionsRef() {
return Input.getInput('githubActionsRef') ?? 'main';
}
// ### ### ###
// GitLab CI provider
// ### ### ###
static get gitlabProjectId() {
return Input.getInput('gitlabProjectId') ?? '';
}
static get gitlabTriggerToken() {
return Input.getInput('gitlabTriggerToken') ?? '';
}
static get gitlabApiUrl() {
return Input.getInput('gitlabApiUrl') ?? 'https://gitlab.com';
}
static get gitlabRef() {
return Input.getInput('gitlabRef') ?? 'main';
}
// ### ### ###
// Ansible provider
// ### ### ###
static get ansibleInventory() {
return Input.getInput('ansibleInventory') ?? '';
}
static get ansiblePlaybook() {
return Input.getInput('ansiblePlaybook') ?? '';
}
static get ansibleExtraVars() {
return Input.getInput('ansibleExtraVars') ?? '';
}
static get ansibleVaultPassword() {
return Input.getInput('ansibleVaultPassword') ?? '';
}
static ToEnvVarFormat(input) {
if (input.toUpperCase() === input) {
return input;
@@ -2501,6 +2577,10 @@ const core = __importStar(__nccwpck_require__(42186));
const test_1 = __importDefault(__nccwpck_require__(6389));
const local_1 = __importDefault(__nccwpck_require__(48195));
const docker_1 = __importDefault(__nccwpck_require__(91739));
const remote_powershell_1 = __importDefault(__nccwpck_require__(90732));
const github_actions_1 = __importDefault(__nccwpck_require__(57511));
const gitlab_ci_1 = __importDefault(__nccwpck_require__(28103));
const ansible_1 = __importDefault(__nccwpck_require__(72073));
const provider_loader_1 = __importDefault(__nccwpck_require__(50822));
const github_1 = __importDefault(__nccwpck_require__(83654));
const shared_workspace_locking_1 = __importDefault(__nccwpck_require__(54222));
@@ -2619,6 +2699,18 @@ class Orchestrator {
case 'local':
Orchestrator.Provider = new local_1.default();
break;
case 'remote-powershell':
Orchestrator.Provider = new remote_powershell_1.default(Orchestrator.buildParameters);
break;
case 'github-actions':
Orchestrator.Provider = new github_actions_1.default(Orchestrator.buildParameters);
break;
case 'gitlab-ci':
Orchestrator.Provider = new gitlab_ci_1.default(Orchestrator.buildParameters);
break;
case 'ansible':
Orchestrator.Provider = new ansible_1.default(Orchestrator.buildParameters);
break;
default:
// Try to load provider using the dynamic loader for unknown providers
try {
@@ -2768,6 +2860,163 @@ Orchestrator.validateAwsTemplates = false;
exports["default"] = Orchestrator;
/***/ }),
/***/ 72073:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
const orchestrator_system_1 = __nccwpck_require__(9744);
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
const provider_resource_1 = __nccwpck_require__(72538);
/**
* 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 {
constructor(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,
// eslint-disable-next-line no-unused-vars
buildParameters,
// eslint-disable-next-line no-unused-vars
branchName,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray) {
orchestrator_logger_1.default.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 orchestrator_system_1.OrchestratorSystem.Run('ansible --version | head -1');
orchestrator_logger_1.default.log(`[Ansible] ${version.trim()}`);
}
catch (error) {
throw new Error(`Ansible not found on PATH: ${error.message || error}`);
}
// Verify inventory exists
try {
await orchestrator_system_1.OrchestratorSystem.Run(`test -e "${this.inventory}"`);
}
catch {
throw new Error(`Inventory not found: ${this.inventory}`);
}
}
async runTaskInWorkflow(buildGuid, image, commands, mountdir, workingdir, environment, secrets) {
orchestrator_logger_1.default.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 = {
// 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 {
orchestrator_logger_1.default.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 orchestrator_system_1.OrchestratorSystem.Run(fullCommand);
orchestrator_logger_1.default.log(`[Ansible] Playbook completed successfully`);
return output;
}
catch (error) {
orchestrator_logger_1.default.logWarning(`[Ansible] Playbook failed: ${error.message || error}`);
throw error;
}
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters,
// eslint-disable-next-line no-unused-vars
branchName,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray) {
orchestrator_logger_1.default.log(`[Ansible] Cleanup complete`);
}
async garbageCollect(
// eslint-disable-next-line no-unused-vars
filter,
// eslint-disable-next-line no-unused-vars
previewOnly,
// eslint-disable-next-line no-unused-vars
olderThan,
// eslint-disable-next-line no-unused-vars
fullCache,
// eslint-disable-next-line no-unused-vars
baseDependencies) {
return '';
}
async listResources() {
if (!this.inventory)
return [];
const resource = new provider_resource_1.ProviderResource();
resource.Name = this.inventory;
return [resource];
}
async listWorkflow() {
return [];
}
async watchWorkflow() {
return '';
}
}
exports["default"] = AnsibleProvider;
/***/ }),
/***/ 41878:
@@ -4926,6 +5175,384 @@ find ${sharedFolder} -maxdepth 1 -type f -name "test-*" -exec cp -a {} /github/w
exports["default"] = LocalDockerOrchestrator;
/***/ }),
/***/ 57511:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
const orchestrator_system_1 = __nccwpck_require__(9744);
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
const provider_resource_1 = __nccwpck_require__(72538);
const provider_workflow_1 = __nccwpck_require__(60511);
/**
* GitHub Actions provider triggers builds as workflow_dispatch events
* on a target repository via the GitHub API.
*
* Use case: Distribute builds across orgs, use specialized runner pools,
* or trigger builds in repos with Unity licenses.
*/
class GitHubActionsProvider {
constructor(buildParameters) {
this.runId = 0;
this.buildParameters = buildParameters;
this.repo = buildParameters.githubActionsRepo || '';
this.workflow = buildParameters.githubActionsWorkflow || '';
this.token = buildParameters.githubActionsToken || '';
this.ref = buildParameters.githubActionsRef || 'main';
}
async setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid,
// eslint-disable-next-line no-unused-vars
buildParameters,
// eslint-disable-next-line no-unused-vars
branchName,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray) {
orchestrator_logger_1.default.log(`[GitHubActions] Setting up workflow dispatch to ${this.repo}`);
if (!this.repo || !this.workflow) {
throw new Error('githubActionsRepo and githubActionsWorkflow are required for the github-actions provider');
}
if (!this.token) {
throw new Error('githubActionsToken is required (PAT with actions:write scope)');
}
// Verify repository and workflow exist
try {
const result = await orchestrator_system_1.OrchestratorSystem.Run(`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/workflows/${this.workflow} --jq '.id'`);
orchestrator_logger_1.default.log(`[GitHubActions] Workflow verified: ${this.workflow} (ID: ${result.trim()})`);
}
catch (error) {
throw new Error(`Failed to verify workflow ${this.workflow} in ${this.repo}: ${error.message || error}`);
}
}
async runTaskInWorkflow(buildGuid, image, commands, mountdir, workingdir, environment,
// eslint-disable-next-line no-unused-vars
secrets) {
orchestrator_logger_1.default.log(`[GitHubActions] Dispatching workflow ${this.workflow} on ${this.repo}@${this.ref}`);
// Build inputs payload
const inputs = {
buildGuid,
image,
commands: Buffer.from(commands).toString('base64'),
mountdir,
workingdir,
};
// Add environment variables as a JSON input
if (environment.length > 0) {
inputs.environment = JSON.stringify(environment.map((element) => ({ name: element.name, value: element.value })));
}
// Record the time before dispatch to identify the run
const beforeDispatch = new Date().toISOString();
// Dispatch the workflow
const inputsJson = JSON.stringify(inputs).replace(/'/g, "'\\''");
try {
await orchestrator_system_1.OrchestratorSystem.Run(`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/workflows/${this.workflow}/dispatches -X POST -f ref='${this.ref}' -f "inputs=${inputsJson}"`);
orchestrator_logger_1.default.log(`[GitHubActions] Workflow dispatched`);
}
catch (error) {
throw new Error(`Failed to dispatch workflow: ${error.message || error}`);
}
// Poll for the run to appear
orchestrator_logger_1.default.log(`[GitHubActions] Waiting for workflow run to start...`);
let attempts = 0;
const maxAttempts = 30;
while (attempts < maxAttempts) {
attempts++;
await new Promise((resolve) => setTimeout(resolve, 10000));
try {
const runsJson = await orchestrator_system_1.OrchestratorSystem.Run(`GH_TOKEN=${this.token} gh api "repos/${this.repo}/actions/workflows/${this.workflow}/runs?created=>${beforeDispatch}&per_page=5" --jq '.workflow_runs[0] | {id, status, conclusion}'`, true);
const run = JSON.parse(runsJson.trim());
if (run.id) {
this.runId = run.id;
orchestrator_logger_1.default.log(`[GitHubActions] Run started: ${this.runId} (status: ${run.status})`);
break;
}
}
catch {
// Run not yet available
}
}
if (!this.runId) {
throw new Error(`Workflow run did not start within ${maxAttempts * 10}s`);
}
// Poll until completion and stream logs
let status = 'in_progress';
while (status === 'in_progress' || status === 'queued') {
await new Promise((resolve) => setTimeout(resolve, 15000));
try {
const statusJson = await orchestrator_system_1.OrchestratorSystem.Run(`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runs/${this.runId} --jq '{status, conclusion}'`, true);
const result = JSON.parse(statusJson.trim());
status = result.status;
if (status === 'completed') {
orchestrator_logger_1.default.log(`[GitHubActions] Run ${this.runId} completed: ${result.conclusion}`);
if (result.conclusion !== 'success') {
throw new Error(`Workflow run failed with conclusion: ${result.conclusion}`);
}
break;
}
orchestrator_logger_1.default.log(`[GitHubActions] Run ${this.runId} status: ${status}`);
}
catch (error) {
if (error.message && error.message.includes('conclusion')) {
throw error;
}
orchestrator_logger_1.default.logWarning(`[GitHubActions] Status check error: ${error.message || error}`);
}
}
// Fetch logs
try {
const logs = await orchestrator_system_1.OrchestratorSystem.Run(`GH_TOKEN=${this.token} gh run view ${this.runId} --repo ${this.repo} --log`, true);
return logs;
}
catch {
return `Run ${this.runId} completed successfully (logs unavailable)`;
}
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters,
// eslint-disable-next-line no-unused-vars
branchName,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray) {
orchestrator_logger_1.default.log(`[GitHubActions] Cleanup complete (no resources to tear down)`);
}
async garbageCollect(
// eslint-disable-next-line no-unused-vars
filter,
// eslint-disable-next-line no-unused-vars
previewOnly,
// eslint-disable-next-line no-unused-vars
olderThan,
// eslint-disable-next-line no-unused-vars
fullCache,
// eslint-disable-next-line no-unused-vars
baseDependencies) {
return '';
}
async listResources() {
if (!this.repo || !this.token)
return [];
try {
const runnersJson = await orchestrator_system_1.OrchestratorSystem.Run(`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runners --jq '.runners[] | .name'`, true);
return runnersJson
.trim()
.split('\n')
.filter(Boolean)
.map((name) => {
const resource = new provider_resource_1.ProviderResource();
resource.Name = name.trim();
return resource;
});
}
catch {
return [];
}
}
async listWorkflow() {
if (!this.repo || !this.token)
return [];
try {
const runsJson = await orchestrator_system_1.OrchestratorSystem.Run(`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runs?per_page=10 --jq '.workflow_runs[] | .name'`, true);
return runsJson
.trim()
.split('\n')
.filter(Boolean)
.map((name) => {
const workflow = new provider_workflow_1.ProviderWorkflow();
workflow.Name = name.trim();
return workflow;
});
}
catch {
return [];
}
}
async watchWorkflow() {
if (!this.runId)
return 'No active run to watch';
try {
return await orchestrator_system_1.OrchestratorSystem.Run(`GH_TOKEN=${this.token} gh run watch ${this.runId} --repo ${this.repo}`, true);
}
catch {
return '';
}
}
}
exports["default"] = GitHubActionsProvider;
/***/ }),
/***/ 28103:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
const orchestrator_system_1 = __nccwpck_require__(9744);
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
const provider_workflow_1 = __nccwpck_require__(60511);
/**
* GitLab CI provider triggers builds as GitLab CI pipelines
* via the GitLab API.
*
* Use case: Teams using GitLab CI, hybrid GitHub/GitLab setups,
* or GitLab runners with Unity licenses.
*/
class GitLabCIProvider {
constructor(buildParameters) {
this.pipelineId = 0;
this.buildParameters = buildParameters;
this.projectId = buildParameters.gitlabProjectId || '';
this.triggerToken = buildParameters.gitlabTriggerToken || '';
this.apiUrl = (buildParameters.gitlabApiUrl || 'https://gitlab.com').replace(/\/+$/, '');
this.ref = buildParameters.gitlabRef || 'main';
}
async setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid,
// eslint-disable-next-line no-unused-vars
buildParameters,
// eslint-disable-next-line no-unused-vars
branchName,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray) {
orchestrator_logger_1.default.log(`[GitLabCI] Setting up pipeline trigger for project ${this.projectId}`);
if (!this.projectId || !this.triggerToken) {
throw new Error('gitlabProjectId and gitlabTriggerToken are required for the gitlab-ci provider');
}
// Verify project access
const encodedProject = encodeURIComponent(this.projectId);
try {
await orchestrator_system_1.OrchestratorSystem.Run(`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}" -o /dev/null`);
orchestrator_logger_1.default.log(`[GitLabCI] Project access verified`);
}
catch (error) {
throw new Error(`Failed to access GitLab project ${this.projectId}: ${error.message || error}`);
}
}
async runTaskInWorkflow(buildGuid, image, commands, mountdir, workingdir, environment,
// eslint-disable-next-line no-unused-vars
secrets) {
orchestrator_logger_1.default.log(`[GitLabCI] Triggering pipeline on project ${this.projectId}@${this.ref}`);
const encodedProject = encodeURIComponent(this.projectId);
// Build variables for the pipeline
const pipelineVariables = [
`-f "variables[BUILD_GUID]=${buildGuid}"`,
`-f "variables[BUILD_IMAGE]=${image}"`,
`-f "variables[BUILD_COMMANDS]=${Buffer.from(commands).toString('base64')}"`,
`-f "variables[MOUNT_DIR]=${mountdir}"`,
`-f "variables[WORKING_DIR]=${workingdir}"`,
];
for (const element of environment) {
pipelineVariables.push(`-f "variables[${element.name}]=${element.value}"`);
}
// Trigger pipeline
try {
const response = await orchestrator_system_1.OrchestratorSystem.Run(`curl -sf -X POST "${this.apiUrl}/api/v4/projects/${encodedProject}/trigger/pipeline" -f "token=${this.triggerToken}" -f "ref=${this.ref}" ${pipelineVariables.join(' ')}`);
const pipeline = JSON.parse(response);
this.pipelineId = pipeline.id;
orchestrator_logger_1.default.log(`[GitLabCI] Pipeline triggered: ${this.pipelineId} (status: ${pipeline.status})`);
}
catch (error) {
throw new Error(`Failed to trigger pipeline: ${error.message || error}`);
}
// Poll until completion
let status = 'pending';
const terminalStatuses = new Set(['success', 'failed', 'canceled', 'skipped']);
while (!terminalStatuses.has(status)) {
await new Promise((resolve) => setTimeout(resolve, 15000));
try {
const statusResponse = await orchestrator_system_1.OrchestratorSystem.Run(`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines/${this.pipelineId}"`, true);
const pipelineStatus = JSON.parse(statusResponse);
status = pipelineStatus.status;
orchestrator_logger_1.default.log(`[GitLabCI] Pipeline ${this.pipelineId} status: ${status}`);
}
catch (error) {
orchestrator_logger_1.default.logWarning(`[GitLabCI] Status check error: ${error.message || error}`);
}
}
if (status !== 'success') {
throw new Error(`Pipeline ${this.pipelineId} finished with status: ${status}`);
}
// Fetch job logs
try {
const jobsResponse = await orchestrator_system_1.OrchestratorSystem.Run(`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines/${this.pipelineId}/jobs"`, true);
const jobs = JSON.parse(jobsResponse);
const logs = [];
for (const job of jobs) {
try {
const jobLog = await orchestrator_system_1.OrchestratorSystem.Run(`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/jobs/${job.id}/trace"`, true);
logs.push(`=== Job: ${job.name} (${job.status}) ===\n${jobLog}`);
}
catch {
logs.push(`=== Job: ${job.name} (${job.status}) === (logs unavailable)`);
}
}
return logs.join('\n\n');
}
catch {
return `Pipeline ${this.pipelineId} completed successfully (logs unavailable)`;
}
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters,
// eslint-disable-next-line no-unused-vars
branchName,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray) {
orchestrator_logger_1.default.log(`[GitLabCI] Cleanup complete`);
}
async garbageCollect(
// eslint-disable-next-line no-unused-vars
filter,
// eslint-disable-next-line no-unused-vars
previewOnly,
// eslint-disable-next-line no-unused-vars
olderThan,
// eslint-disable-next-line no-unused-vars
fullCache,
// eslint-disable-next-line no-unused-vars
baseDependencies) {
return '';
}
async listResources() {
return [];
}
async listWorkflow() {
if (!this.projectId || !this.triggerToken)
return [];
try {
const encodedProject = encodeURIComponent(this.projectId);
const response = await orchestrator_system_1.OrchestratorSystem.Run(`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines?per_page=10"`, true);
return JSON.parse(response).map((pipeline) => {
const workflow = new provider_workflow_1.ProviderWorkflow();
workflow.Name = `Pipeline #${pipeline.id} (${pipeline.status})`;
return workflow;
});
}
catch {
return [];
}
}
async watchWorkflow() {
return '';
}
}
exports["default"] = GitLabCIProvider;
/***/ }),
/***/ 66990:
@@ -7146,6 +7773,20 @@ class ProviderLoader {
exports.ProviderLoader = ProviderLoader;
/***/ }),
/***/ 72538:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.ProviderResource = void 0;
class ProviderResource {
}
exports.ProviderResource = ProviderResource;
/***/ }),
/***/ 26162:
@@ -7267,6 +7908,146 @@ function logProviderSource(source, parsed) {
exports.logProviderSource = logProviderSource;
/***/ }),
/***/ 60511:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.ProviderWorkflow = void 0;
class ProviderWorkflow {
}
exports.ProviderWorkflow = ProviderWorkflow;
/***/ }),
/***/ 90732:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
const orchestrator_system_1 = __nccwpck_require__(9744);
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
const provider_resource_1 = __nccwpck_require__(72538);
/**
* Remote PowerShell provider executes Unity builds on remote machines
* via PowerShell Remoting (WinRM or SSH).
*
* Use case: Teams with dedicated build machines not part of a CI system.
*/
class RemotePowershellProvider {
constructor(buildParameters) {
this.sessionId = '';
this.buildParameters = buildParameters;
this.host = buildParameters.remotePowershellHost || '';
this.transport = buildParameters.remotePowershellTransport || 'wsman';
this.credential = buildParameters.remotePowershellCredential || '';
}
async setupWorkflow(buildGuid,
// eslint-disable-next-line no-unused-vars
buildParameters,
// eslint-disable-next-line no-unused-vars
branchName,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray) {
orchestrator_logger_1.default.log(`[RemotePowershell] Setting up remote session to ${this.host} via ${this.transport}`);
if (!this.host) {
throw new Error('remotePowershellHost is required for the remote-powershell provider');
}
// Test connectivity
const testCommand = this.buildPwshCommand(`Test-WSMan -ComputerName "${this.host}" -ErrorAction Stop`);
try {
await orchestrator_system_1.OrchestratorSystem.Run(testCommand);
orchestrator_logger_1.default.log(`[RemotePowershell] Connection test passed`);
}
catch (error) {
throw new Error(`Failed to connect to remote host ${this.host}: ${error.message || error}`);
}
this.sessionId = buildGuid;
orchestrator_logger_1.default.log(`[RemotePowershell] Session ${this.sessionId} ready`);
}
async runTaskInWorkflow(buildGuid, image, commands, mountdir, workingdir, environment, secrets) {
orchestrator_logger_1.default.log(`[RemotePowershell] Executing task on ${this.host}`);
// Build environment variable block for remote session
const environmentBlock = environment.map((element) => `$env:${element.name} = '${element.value}'`).join('; ');
const secretBlock = secrets
.map((secret) => `$env:${secret.EnvironmentVariable} = '${secret.ParameterValue}'`)
.join('; ');
// Wrap commands for remote execution
const remoteScript = [environmentBlock, secretBlock, `Set-Location "${workingdir}"`, commands]
.filter(Boolean)
.join('; ');
const invokeCommand = this.buildInvokeCommand(remoteScript);
try {
const output = await orchestrator_system_1.OrchestratorSystem.Run(invokeCommand);
orchestrator_logger_1.default.log(`[RemotePowershell] Task completed successfully`);
return output;
}
catch (error) {
orchestrator_logger_1.default.logWarning(`[RemotePowershell] Task failed: ${error.message || error}`);
throw error;
}
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters,
// eslint-disable-next-line no-unused-vars
branchName,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray) {
orchestrator_logger_1.default.log(`[RemotePowershell] Cleaning up session ${this.sessionId}`);
// Remote sessions are stateless per invocation — no cleanup needed
}
async garbageCollect(
// eslint-disable-next-line no-unused-vars
filter,
// eslint-disable-next-line no-unused-vars
previewOnly,
// eslint-disable-next-line no-unused-vars
olderThan,
// eslint-disable-next-line no-unused-vars
fullCache,
// eslint-disable-next-line no-unused-vars
baseDependencies) {
orchestrator_logger_1.default.log(`[RemotePowershell] Garbage collection not supported for remote PowerShell provider`);
return '';
}
async listResources() {
const resource = new provider_resource_1.ProviderResource();
resource.Name = this.host;
return [resource];
}
async listWorkflow() {
return [];
}
async watchWorkflow() {
return '';
}
buildPwshCommand(script) {
return `pwsh -NoProfile -NonInteractive -Command "${script.replace(/"/g, '\\"')}"`;
}
buildInvokeCommand(remoteScript) {
const escapedScript = remoteScript.replace(/"/g, '\\"').replace(/'/g, "''");
if (this.transport === 'ssh') {
return `pwsh -NoProfile -NonInteractive -Command "Invoke-Command -HostName '${this.host}' -ScriptBlock { ${escapedScript} }"`;
}
// WinRM (default)
const credentialPart = this.credential
? `-Credential (New-Object PSCredential('${this.credential.split(':')[0]}', (ConvertTo-SecureString '${this.credential.split(':')[1]}' -AsPlainText -Force)))`
: '';
return `pwsh -NoProfile -NonInteractive -Command "Invoke-Command -ComputerName '${this.host}' ${credentialPart} -ScriptBlock { ${escapedScript} }"`;
}
}
exports["default"] = RemotePowershellProvider;
/***/ }),
/***/ 6389:

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -107,6 +107,29 @@ class BuildParameters {
public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: 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 !== ``;
}
@@ -242,6 +265,29 @@ class BuildParameters {
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath,
// 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

@@ -282,6 +282,82 @@ class Input {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
// ### ### ###
// 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

@@ -13,6 +13,10 @@ import OrchestratorEnvironmentVariable from './options/orchestrator-environment-
import TestOrchestrator from './providers/test';
import LocalOrchestrator from './providers/local';
import LocalDockerOrchestrator from './providers/docker';
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';
@@ -158,6 +162,18 @@ class Orchestrator {
case 'local':
Orchestrator.Provider = new LocalOrchestrator();
break;
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 {

View File

@@ -0,0 +1,187 @@
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 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,267 @@
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';
/**
* GitHub Actions provider — triggers builds as workflow_dispatch events
* on a target repository via the GitHub API.
*
* Use case: Distribute builds across orgs, use specialized runner pools,
* or trigger builds in repos with Unity licenses.
*/
class GitHubActionsProvider implements ProviderInterface {
private buildParameters: BuildParameters;
private repo: string;
private workflow: string;
private token: string;
private ref: string;
private runId: number = 0;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.repo = buildParameters.githubActionsRepo || '';
this.workflow = buildParameters.githubActionsWorkflow || '';
this.token = buildParameters.githubActionsToken || '';
this.ref = buildParameters.githubActionsRef || 'main';
}
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(`[GitHubActions] Setting up workflow dispatch to ${this.repo}`);
if (!this.repo || !this.workflow) {
throw new Error('githubActionsRepo and githubActionsWorkflow are required for the github-actions provider');
}
if (!this.token) {
throw new Error('githubActionsToken is required (PAT with actions:write scope)');
}
// Verify repository and workflow exist
try {
const result = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/workflows/${this.workflow} --jq '.id'`,
);
OrchestratorLogger.log(`[GitHubActions] Workflow verified: ${this.workflow} (ID: ${result.trim()})`);
} catch (error: any) {
throw new Error(`Failed to verify workflow ${this.workflow} in ${this.repo}: ${error.message || error}`);
}
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
// eslint-disable-next-line no-unused-vars
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[GitHubActions] Dispatching workflow ${this.workflow} on ${this.repo}@${this.ref}`);
// Build inputs payload
const inputs: Record<string, string> = {
buildGuid,
image,
commands: Buffer.from(commands).toString('base64'),
mountdir,
workingdir,
};
// Add environment variables as a JSON input
if (environment.length > 0) {
inputs.environment = JSON.stringify(environment.map((element) => ({ name: element.name, value: element.value })));
}
// Record the time before dispatch to identify the run
const beforeDispatch = new Date().toISOString();
// Dispatch the workflow
const inputsJson = JSON.stringify(inputs).replace(/'/g, "'\\''");
try {
await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/workflows/${this.workflow}/dispatches -X POST -f ref='${this.ref}' -f "inputs=${inputsJson}"`,
);
OrchestratorLogger.log(`[GitHubActions] Workflow dispatched`);
} catch (error: any) {
throw new Error(`Failed to dispatch workflow: ${error.message || error}`);
}
// Poll for the run to appear
OrchestratorLogger.log(`[GitHubActions] Waiting for workflow run to start...`);
let attempts = 0;
const maxAttempts = 30;
while (attempts < maxAttempts) {
attempts++;
await new Promise((resolve) => setTimeout(resolve, 10_000));
try {
const runsJson = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api "repos/${this.repo}/actions/workflows/${this.workflow}/runs?created=>${beforeDispatch}&per_page=5" --jq '.workflow_runs[0] | {id, status, conclusion}'`,
true,
);
const run = JSON.parse(runsJson.trim());
if (run.id) {
this.runId = run.id;
OrchestratorLogger.log(`[GitHubActions] Run started: ${this.runId} (status: ${run.status})`);
break;
}
} catch {
// Run not yet available
}
}
if (!this.runId) {
throw new Error(`Workflow run did not start within ${maxAttempts * 10}s`);
}
// Poll until completion and stream logs
let status = 'in_progress';
while (status === 'in_progress' || status === 'queued') {
await new Promise((resolve) => setTimeout(resolve, 15_000));
try {
const statusJson = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runs/${this.runId} --jq '{status, conclusion}'`,
true,
);
const result = JSON.parse(statusJson.trim());
status = result.status;
if (status === 'completed') {
OrchestratorLogger.log(`[GitHubActions] Run ${this.runId} completed: ${result.conclusion}`);
if (result.conclusion !== 'success') {
throw new Error(`Workflow run failed with conclusion: ${result.conclusion}`);
}
break;
}
OrchestratorLogger.log(`[GitHubActions] Run ${this.runId} status: ${status}`);
} catch (error: any) {
if (error.message && error.message.includes('conclusion')) {
throw error;
}
OrchestratorLogger.logWarning(`[GitHubActions] Status check error: ${error.message || error}`);
}
}
// Fetch logs
try {
const logs = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh run view ${this.runId} --repo ${this.repo} --log`,
true,
);
return logs;
} catch {
return `Run ${this.runId} completed successfully (logs unavailable)`;
}
}
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(`[GitHubActions] Cleanup complete (no resources to tear down)`);
}
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.repo || !this.token) return [];
try {
const runnersJson = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runners --jq '.runners[] | .name'`,
true,
);
return runnersJson
.trim()
.split('\n')
.filter(Boolean)
.map((name) => {
const resource = new ProviderResource();
resource.Name = name.trim();
return resource;
});
} catch {
return [];
}
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
if (!this.repo || !this.token) return [];
try {
const runsJson = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runs?per_page=10 --jq '.workflow_runs[] | .name'`,
true,
);
return runsJson
.trim()
.split('\n')
.filter(Boolean)
.map((name) => {
const workflow = new ProviderWorkflow();
workflow.Name = name.trim();
return workflow;
});
} catch {
return [];
}
}
async watchWorkflow(): Promise<string> {
if (!this.runId) return 'No active run to watch';
try {
return await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh run watch ${this.runId} --repo ${this.repo}`,
true,
);
} catch {
return '';
}
}
}
export default GitHubActionsProvider;

View File

@@ -0,0 +1,211 @@
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';
/**
* GitLab CI provider — triggers builds as GitLab CI pipelines
* via the GitLab API.
*
* Use case: Teams using GitLab CI, hybrid GitHub/GitLab setups,
* or GitLab runners with Unity licenses.
*/
class GitLabCIProvider implements ProviderInterface {
private buildParameters: BuildParameters;
private projectId: string;
private triggerToken: string;
private apiUrl: string;
private ref: string;
private pipelineId: number = 0;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.projectId = buildParameters.gitlabProjectId || '';
this.triggerToken = buildParameters.gitlabTriggerToken || '';
this.apiUrl = (buildParameters.gitlabApiUrl || 'https://gitlab.com').replace(/\/+$/, '');
this.ref = buildParameters.gitlabRef || 'main';
}
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(`[GitLabCI] Setting up pipeline trigger for project ${this.projectId}`);
if (!this.projectId || !this.triggerToken) {
throw new Error('gitlabProjectId and gitlabTriggerToken are required for the gitlab-ci provider');
}
// Verify project access
const encodedProject = encodeURIComponent(this.projectId);
try {
await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}" -o /dev/null`,
);
OrchestratorLogger.log(`[GitLabCI] Project access verified`);
} catch (error: any) {
throw new Error(`Failed to access GitLab project ${this.projectId}: ${error.message || error}`);
}
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
// eslint-disable-next-line no-unused-vars
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[GitLabCI] Triggering pipeline on project ${this.projectId}@${this.ref}`);
const encodedProject = encodeURIComponent(this.projectId);
// Build variables for the pipeline
const pipelineVariables: string[] = [
`-f "variables[BUILD_GUID]=${buildGuid}"`,
`-f "variables[BUILD_IMAGE]=${image}"`,
`-f "variables[BUILD_COMMANDS]=${Buffer.from(commands).toString('base64')}"`,
`-f "variables[MOUNT_DIR]=${mountdir}"`,
`-f "variables[WORKING_DIR]=${workingdir}"`,
];
for (const element of environment) {
pipelineVariables.push(`-f "variables[${element.name}]=${element.value}"`);
}
// Trigger pipeline
try {
const response = await OrchestratorSystem.Run(
`curl -sf -X POST "${this.apiUrl}/api/v4/projects/${encodedProject}/trigger/pipeline" -f "token=${
this.triggerToken
}" -f "ref=${this.ref}" ${pipelineVariables.join(' ')}`,
);
const pipeline = JSON.parse(response);
this.pipelineId = pipeline.id;
OrchestratorLogger.log(`[GitLabCI] Pipeline triggered: ${this.pipelineId} (status: ${pipeline.status})`);
} catch (error: any) {
throw new Error(`Failed to trigger pipeline: ${error.message || error}`);
}
// Poll until completion
let status = 'pending';
const terminalStatuses = new Set(['success', 'failed', 'canceled', 'skipped']);
while (!terminalStatuses.has(status)) {
await new Promise((resolve) => setTimeout(resolve, 15_000));
try {
const statusResponse = await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines/${this.pipelineId}"`,
true,
);
const pipelineStatus = JSON.parse(statusResponse);
status = pipelineStatus.status;
OrchestratorLogger.log(`[GitLabCI] Pipeline ${this.pipelineId} status: ${status}`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[GitLabCI] Status check error: ${error.message || error}`);
}
}
if (status !== 'success') {
throw new Error(`Pipeline ${this.pipelineId} finished with status: ${status}`);
}
// Fetch job logs
try {
const jobsResponse = await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines/${this.pipelineId}/jobs"`,
true,
);
const jobs = JSON.parse(jobsResponse);
const logs: string[] = [];
for (const job of jobs) {
try {
const jobLog = await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/jobs/${job.id}/trace"`,
true,
);
logs.push(`=== Job: ${job.name} (${job.status}) ===\n${jobLog}`);
} catch {
logs.push(`=== Job: ${job.name} (${job.status}) === (logs unavailable)`);
}
}
return logs.join('\n\n');
} catch {
return `Pipeline ${this.pipelineId} completed successfully (logs unavailable)`;
}
}
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(`[GitLabCI] 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[]> {
return [];
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
if (!this.projectId || !this.triggerToken) return [];
try {
const encodedProject = encodeURIComponent(this.projectId);
const response = await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines?per_page=10"`,
true,
);
return JSON.parse(response).map((pipeline: any) => {
const workflow = new ProviderWorkflow();
workflow.Name = `Pipeline #${pipeline.id} (${pipeline.status})`;
return workflow;
});
} catch {
return [];
}
}
async watchWorkflow(): Promise<string> {
return '';
}
}
export default GitLabCIProvider;

View File

@@ -0,0 +1,160 @@
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';
/**
* Remote PowerShell provider — executes Unity builds on remote machines
* via PowerShell Remoting (WinRM or SSH).
*
* Use case: Teams with dedicated build machines not part of a CI system.
*/
class RemotePowershellProvider implements ProviderInterface {
private buildParameters: BuildParameters;
private host: string;
private transport: string;
private credential: string;
private sessionId: string = '';
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.host = buildParameters.remotePowershellHost || '';
this.transport = buildParameters.remotePowershellTransport || 'wsman';
this.credential = buildParameters.remotePowershellCredential || '';
}
async setupWorkflow(
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(`[RemotePowershell] Setting up remote session to ${this.host} via ${this.transport}`);
if (!this.host) {
throw new Error('remotePowershellHost is required for the remote-powershell provider');
}
// Test connectivity
const testCommand = this.buildPwshCommand(`Test-WSMan -ComputerName "${this.host}" -ErrorAction Stop`);
try {
await OrchestratorSystem.Run(testCommand);
OrchestratorLogger.log(`[RemotePowershell] Connection test passed`);
} catch (error: any) {
throw new Error(`Failed to connect to remote host ${this.host}: ${error.message || error}`);
}
this.sessionId = buildGuid;
OrchestratorLogger.log(`[RemotePowershell] Session ${this.sessionId} ready`);
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[RemotePowershell] Executing task on ${this.host}`);
// Build environment variable block for remote session
const environmentBlock = environment.map((element) => `$env:${element.name} = '${element.value}'`).join('; ');
const secretBlock = secrets
.map((secret) => `$env:${secret.EnvironmentVariable} = '${secret.ParameterValue}'`)
.join('; ');
// Wrap commands for remote execution
const remoteScript = [environmentBlock, secretBlock, `Set-Location "${workingdir}"`, commands]
.filter(Boolean)
.join('; ');
const invokeCommand = this.buildInvokeCommand(remoteScript);
try {
const output = await OrchestratorSystem.Run(invokeCommand);
OrchestratorLogger.log(`[RemotePowershell] Task completed successfully`);
return output;
} catch (error: any) {
OrchestratorLogger.logWarning(`[RemotePowershell] Task 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(`[RemotePowershell] Cleaning up session ${this.sessionId}`);
// Remote sessions are stateless per invocation — no cleanup needed
}
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> {
OrchestratorLogger.log(`[RemotePowershell] Garbage collection not supported for remote PowerShell provider`);
return '';
}
async listResources(): Promise<ProviderResource[]> {
const resource = new ProviderResource();
resource.Name = this.host;
return [resource];
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
return [];
}
async watchWorkflow(): Promise<string> {
return '';
}
private buildPwshCommand(script: string): string {
return `pwsh -NoProfile -NonInteractive -Command "${script.replace(/"/g, '\\"')}"`;
}
private buildInvokeCommand(remoteScript: string): string {
const escapedScript = remoteScript.replace(/"/g, '\\"').replace(/'/g, "''");
if (this.transport === 'ssh') {
return `pwsh -NoProfile -NonInteractive -Command "Invoke-Command -HostName '${this.host}' -ScriptBlock { ${escapedScript} }"`;
}
// WinRM (default)
const credentialPart = this.credential
? `-Credential (New-Object PSCredential('${this.credential.split(':')[0]}', (ConvertTo-SecureString '${
this.credential.split(':')[1]
}' -AsPlainText -Force)))`
: '';
return `pwsh -NoProfile -NonInteractive -Command "Invoke-Command -ComputerName '${this.host}' ${credentialPart} -ScriptBlock { ${escapedScript} }"`;
}
}
export default RemotePowershellProvider;