1
0
mirror of git://f0xx.org/ac/ac-ms-build synced 2026-07-29 03:38:09 +03:00

feat(build): error sanitizer and failed-build notifier

Signed-off-by: Anton Afanasyeu <a.afanasieff@gmail.com>
This commit is contained in:
Anton Afanasyeu
2026-07-12 13:53:15 +02:00
parent 52de65e52b
commit b8000a712f
3 changed files with 381 additions and 15 deletions

View File

@@ -94,6 +94,9 @@ YAML;
public static function enqueue(array $params, ?int $userId): array {
BuildRepository::ensureSchema();
if (!isset($params['trigger_source'])) {
$params['trigger_source'] = $userId !== null ? 'manual' : 'agent';
}
$params['pipeline_yaml'] = $params['pipeline_yaml'] ?? self::resolvePipelineYaml($params);
$buildCode = self::generateBuildCode();
$id = BuildRepository::create([
@@ -138,6 +141,8 @@ YAML;
. ' -C ' . escapeshellarg($repo)
. ' remote get-url origin 2>/dev/null'
));
$mobileSub = trim((string) cfg('build.mobile_subdir', 'ac-mobile-android'));
$scriptsDir = trim((string) cfg('build.scripts_dir', dirname((string) cfg('build.runner_script', ''))));
$env = [
'BUILD_ID=' . $id,
'BUILD_LOG_FILE=' . escapeshellarg($logPath),
@@ -146,6 +151,9 @@ YAML;
'BUILD_GIT_DEPTH=' . max(1, (int) cfg('build.git_clone_depth', 1)),
'BUILD_WORK_DIR=' . escapeshellarg($repo),
'BUILD_OUT_DIR=' . escapeshellarg($dir),
'BUILD_MOBILE_SUBDIR=' . escapeshellarg($mobileSub),
'BUILD_SCRIPTS_DIR=' . escapeshellarg($scriptsDir !== '' ? $scriptsDir : dirname($runner)),
'BUILD_DOCKERFILE=' . escapeshellarg((string) cfg('build.dockerfile', '')),
'ANDROIDCAST_CI_VERSION=' . escapeshellarg((string) cfg('build.ci_version', '00.01.00.1000')),
'GIT_REF=' . escapeshellarg((string) ($params['git_ref'] ?? '')),
'GIT_SHA=' . escapeshellarg((string) ($params['git_sha'] ?? '')),
@@ -185,15 +193,20 @@ YAML;
}
public static function failBuild(int $id, string $message, ?string $logPath = null): void {
$safe = BuildErrorSanitizer::sanitize($message);
if ($logPath) {
file_put_contents($logPath, "\n[builder] ERROR: {$message}\n", FILE_APPEND);
file_put_contents($logPath, "\n[builder] ERROR: {$safe}\n", FILE_APPEND);
}
BuildRepository::update($id, [
'status' => 'failed',
'phase' => 'error',
'error_message' => substr($message, 0, 512),
'error_message' => substr($safe, 0, 512),
'finished_at' => gmdate('Y-m-d H:i:s'),
]);
$build = BuildRepository::getById($id);
if ($build) {
BuildNotifier::onFailure($id, $build, $safe);
}
}
public static function stopBuild(int $id): bool {
@@ -276,6 +289,14 @@ YAML;
/** CircleCI-style: resume from the step that failed (git → docker-build → docker-run). */
public static function detectFailedStep(int $buildId): string {
$build = BuildRepository::getById($buildId);
if ($build) {
$err = (string) ($build['error_message'] ?? '');
$started = (string) ($build['started_at'] ?? '');
if ($started === '' && str_contains($err, 'Docker daemon not available')) {
return 'docker-setup';
}
}
$dir = self::artifactDir($buildId);
$logTail = '';
$logPath = $dir . '/build.log';
@@ -456,7 +477,7 @@ YAML;
if (strlen($summary) > 480) {
$summary = '…' . substr($summary, -477);
}
return 'Build runner exited unexpectedly: ' . $summary;
return BuildErrorSanitizer::sanitize('Build runner exited unexpectedly: ' . $summary);
}
public static function readLogTail(?string $logPath, int $offset = 0, int $maxBytes = 65536): array {
@@ -508,13 +529,27 @@ YAML;
$versionApp = $info['gitSha'] ?? null;
}
}
BuildRepository::update($buildId, [
$update = [
'status' => $status,
'phase' => 'done',
'artifacts_json' => json_encode($artifacts, JSON_UNESCAPED_SLASHES),
'version_app' => $versionApp,
'finished_at' => gmdate('Y-m-d H:i:s'),
]);
];
if ($status === 'failed') {
$update['error_message'] = substr(
BuildErrorSanitizer::sanitize('Build finished without APK artifact'),
0,
512
);
}
BuildRepository::update($buildId, $update);
if ($status === 'failed') {
$build = BuildRepository::getById($buildId);
if ($build) {
BuildNotifier::onFailure($buildId, $build, (string) ($update['error_message'] ?? 'Build failed'));
}
}
if (!empty($artifacts['ota']) && cfg('build.ota_mount')) {
$build = BuildRepository::getById($buildId);
if (!empty($build['auto_deploy'])) {
@@ -533,19 +568,86 @@ YAML;
return;
}
$build = BuildRepository::getById($buildId);
$channel = $build['ota_channel'] ?? 'staging';
$channel = (string) ($build['ota_channel'] ?? 'staging');
$dest = $mount . '/v0';
shell_exec('mkdir -p ' . escapeshellarg($dest) . ' && cp -a ' . escapeshellarg($src . '/.') . ' ' . escapeshellarg($dest . '/'));
if ($channel !== '' && $channel !== 'staging') {
$channelJson = $src . '/ota/channel/' . $channel . '.json';
if (is_file($channelJson)) {
shell_exec(
'cp -a '
. escapeshellarg($channelJson)
. ' '
. escapeshellarg($dest . '/ota/channel/' . $channel . '.json')
);
$channelJson = $src . '/ota/channel/' . $channel . '.json';
if (is_file($channelJson)) {
shell_exec(
'mkdir -p '
. escapeshellarg($dest . '/ota/channel')
. ' && cp -a '
. escapeshellarg($channelJson)
. ' '
. escapeshellarg($dest . '/ota/channel/' . $channel . '.json')
);
}
self::publishBrowserApk($src, $channel);
}
/** Copy installable .apk to hub /v0/downloads/ for browser sideload. */
private static function publishBrowserApk(string $otaV0Dir, string $channel): void {
$downloads = rtrim((string) cfg('build.downloads_mount', ''), '/');
if ($downloads === '') {
$parent = dirname(rtrim((string) cfg('build.ota_mount', ''), '/'));
$downloads = $parent !== '' && $parent !== '.'
? $parent . '/downloads'
: '/var/www/localhost/htdocs/apps/app/androidcast_project/downloads';
}
if (!is_dir($downloads) && !@mkdir($downloads, 0775, true) && !is_dir($downloads)) {
return;
}
$apkPath = self::resolveBrowserApkPath($otaV0Dir, $channel);
if ($apkPath === null || !is_file($apkPath)) {
return;
}
$latest = $downloads . '/android_cast-latest.apk';
$versioned = $downloads . '/' . basename($apkPath);
@copy($apkPath, $latest);
@copy($apkPath, $versioned);
@chown($latest, 'nginx');
@chgrp($latest, 'nginx');
@chown($versioned, 'nginx');
@chgrp($versioned, 'nginx');
}
private static function resolveBrowserApkPath(string $otaV0Dir, string $channel): ?string {
$channelFile = $otaV0Dir . '/ota/channel/' . $channel . '.json';
if (is_file($channelFile)) {
$channelData = json_decode((string) file_get_contents($channelFile), true);
$manifestUrl = is_array($channelData) ? (string) ($channelData['manifestUrl'] ?? '') : '';
if ($manifestUrl !== '') {
$manifestPath = self::otaUrlToLocalPath($manifestUrl, $otaV0Dir);
if ($manifestPath !== null && is_file($manifestPath)) {
$manifest = json_decode((string) file_get_contents($manifestPath), true);
if (is_array($manifest)) {
$browser = (string) ($manifest['browserApkUrl'] ?? '');
if ($browser !== '') {
$local = self::otaUrlToLocalPath($browser, $otaV0Dir);
if ($local !== null && is_file($local)) {
return $local;
}
}
}
}
}
}
$glob = glob($otaV0Dir . '/ota/*/*/*/*.apk') ?: [];
if ($glob !== []) {
usort($glob, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a));
return $glob[0];
}
return null;
}
private static function otaUrlToLocalPath(string $url, string $otaV0Dir): ?string {
$path = (string) parse_url($url, PHP_URL_PATH);
$prefix = '/v0/ota/';
if (!str_starts_with($path, $prefix)) {
return null;
}
$rel = substr($path, strlen($prefix));
$local = $otaV0Dir . '/ota/' . $rel;
return is_file($local) ? $local : null;
}
}