mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-11 00:13:56 -07:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user