Files
unity-builder/src/model/orchestrator/providers/github-actions/index.ts
frostebite 120c3c5b24 fix(providers): add polling timeouts, fix credential parsing, validate dependencies
- GitHub Actions: max 4-hour polling with clear timeout error including run URL
- GitLab CI: max 4-hour polling with clear timeout error including pipeline URL
- Remote PowerShell: fix credential split to preserve passwords with colons
  (split on first colon only instead of all colons)
- Remote PowerShell: throw clear error when credential format is invalid
- Ansible: validate ansible-playbook binary exists in setupWorkflow
  (separate from ansible --version check)
- All timeout errors use core.error() for GitHub Actions annotation visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:04:15 +00:00

285 lines
9.4 KiB
TypeScript

import * as core from '@actions/core';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
const MAX_POLLING_DURATION_MS = 14_400_000; // 4 hours
/**
* 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 (with maximum duration guard)
let status = 'in_progress';
const pollingStartTime = Date.now();
const runUrl = `https://github.com/${this.repo}/actions/runs/${this.runId}`;
while (status === 'in_progress' || status === 'queued') {
const elapsedMs = Date.now() - pollingStartTime;
if (elapsedMs >= MAX_POLLING_DURATION_MS) {
const hours = Math.round(MAX_POLLING_DURATION_MS / 3_600_000);
const message = `GitHub Actions workflow did not complete within ${hours} hours. Run URL: ${runUrl}`;
core.error(message);
throw new Error(message);
}
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;
}
if (error.message && error.message.includes('did not complete within')) {
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;