mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-11 16:34:03 -07:00
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>
This commit is contained in:
@@ -2,9 +2,17 @@ import GitHubActionsProvider from '.';
|
||||
import BuildParameters from '../../../build-parameters';
|
||||
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
|
||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||
import * as core from '@actions/core';
|
||||
|
||||
jest.mock('../../services/core/orchestrator-system');
|
||||
jest.mock('../../services/core/orchestrator-logger');
|
||||
jest.mock('@actions/core', () => ({
|
||||
info: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
error: jest.fn(),
|
||||
setOutput: jest.fn(),
|
||||
getInput: jest.fn(() => ''),
|
||||
}));
|
||||
|
||||
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
|
||||
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
|
||||
@@ -185,9 +193,9 @@ describe('GitHubActionsProvider', () => {
|
||||
mockRun.mockRejectedValueOnce(new Error('not found'));
|
||||
}
|
||||
|
||||
await expect(
|
||||
provider.runTaskInWorkflow('guid-timeout', 'img', 'cmd', '/m', '/w', [], []),
|
||||
).rejects.toThrow('Workflow run did not start within');
|
||||
await expect(provider.runTaskInWorkflow('guid-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||
'Workflow run did not start within',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when workflow run fails with non-success conclusion', async () => {
|
||||
@@ -195,9 +203,9 @@ describe('GitHubActionsProvider', () => {
|
||||
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 300, status: 'in_progress' })); // run appears
|
||||
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'failure' })); // fails
|
||||
|
||||
await expect(
|
||||
provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], []),
|
||||
).rejects.toThrow('Workflow run failed with conclusion: failure');
|
||||
await expect(provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||
'Workflow run failed with conclusion: failure',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns fallback message when log fetch fails', async () => {
|
||||
@@ -217,9 +225,39 @@ describe('GitHubActionsProvider', () => {
|
||||
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 500, status: 'in_progress' })); // run
|
||||
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'cancelled' })); // cancelled
|
||||
|
||||
await expect(
|
||||
provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], []),
|
||||
).rejects.toThrow('Workflow run failed with conclusion: cancelled');
|
||||
await expect(provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||
'Workflow run failed with conclusion: cancelled',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws timeout error when polling exceeds maximum duration', async () => {
|
||||
// Save real Date.now
|
||||
const realDateNow = Date.now;
|
||||
let callCount = 0;
|
||||
|
||||
// dispatch succeeds
|
||||
mockRun.mockResolvedValueOnce('');
|
||||
// run appears
|
||||
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 600, status: 'in_progress' }));
|
||||
// Status always returns in_progress
|
||||
mockRun.mockImplementation(() => Promise.resolve(JSON.stringify({ status: 'in_progress' })));
|
||||
|
||||
// First call returns normal time, subsequent calls simulate 5 hours elapsed
|
||||
Date.now = () => {
|
||||
callCount++;
|
||||
if (callCount <= 2) return realDateNow.call(Date);
|
||||
return realDateNow.call(Date) + 14_400_001; // 4 hours + 1ms
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(provider.runTaskInWorkflow('guid-poll-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||
'did not complete within 4 hours',
|
||||
);
|
||||
|
||||
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('did not complete within 4 hours'));
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -7,6 +8,8 @@ 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.
|
||||
@@ -131,9 +134,20 @@ class GitHubActionsProvider implements ProviderInterface {
|
||||
throw new Error(`Workflow run did not start within ${maxAttempts * 10}s`);
|
||||
}
|
||||
|
||||
// Poll until completion and stream logs
|
||||
// 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 {
|
||||
@@ -160,6 +174,9 @@ class GitHubActionsProvider implements ProviderInterface {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user