From e8039aab780b708eea536ef3cae1ee48e039b0ff Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Wed, 12 Aug 2026 14:08:12 +0200 Subject: [PATCH] Builder: monotonic OTA versions, duration UI, OTA publish fixes. VersionAllocator for AA.BB.CC.DDDD, build elapsed display, branded QR alerts, shared cache env, and auto_deploy when auto_ota is set. --- config/config.example.php | 9 +++ public/api/build_trigger.php | 10 +++ public/assets/js/builder.js | 97 ++++++++++++++++++++++++- src/BuildDuration.php | 89 +++++++++++++++++++++++ src/BuildNotifier.php | 62 ++++++++++++++-- src/BuildRepository.php | 7 +- src/BuildRunner.php | 107 +++++++++++++++++++++++++--- src/VersionAllocator.php | 133 +++++++++++++++++++++++++++++++++++ src/bootstrap.php | 15 ++++ views/build_detail.php | 22 ++++++ views/home.php | 13 +++- views/layout.php | 5 +- 12 files changed, 548 insertions(+), 21 deletions(-) create mode 100644 src/BuildDuration.php create mode 100644 src/VersionAllocator.php diff --git a/config/config.example.php b/config/config.example.php index a7d6c9e..2d378e9 100644 --- a/config/config.example.php +++ b/config/config.example.php @@ -26,6 +26,12 @@ return [ 'docker_image' => 'androidcast-ci:latest', 'docker_bin' => '/usr/bin/docker', 'ci_version' => '00.01.00.1000', + 'version' => [ + 'major' => 0, + 'minor' => 1, + 'patch' => 0, + 'build_floor' => 1, + ], 'repo_root' => '/workspace', 'artifacts_root' => '/workspace/out/builds', 'ota_mount' => '/workspace/orchestration/runtime/ota-artifacts', @@ -39,6 +45,9 @@ return [ 'default_channels' => ['stable', 'staging', 'dev', 'nightly'], 'mobile_subdir' => 'ac-mobile-android', 'scripts_dir' => '/workspace/ac-scripts', + 'cache_ccache_dir' => '/workspace/out/builds/.cache/ccache', + 'cache_gradle_dir' => '/workspace/out/builds/.cache/gradle', + 'keep_src_dirs' => 10, 'notify' => [ 'admin_email' => 'bestcastr@gmail.com', 'broadcast_config' => '/var/www/ac/broadcast/config/config.php', diff --git a/public/api/build_trigger.php b/public/api/build_trigger.php index ced2e5a..fad76b3 100644 --- a/public/api/build_trigger.php +++ b/public/api/build_trigger.php @@ -19,6 +19,9 @@ $params['run_native'] = array_key_exists('run_native', $params) ? !empty($params $params['run_apk'] = array_key_exists('run_apk', $params) ? !empty($params['run_apk']) : true; $params['auto_ota'] = !empty($params['auto_ota']); $params['auto_deploy'] = !empty($params['auto_deploy']); +if ($params['auto_ota']) { + $params['auto_deploy'] = true; +} $params['ota_channel'] = $params['ota_channel'] ?? 'staging'; $params['gradle_task'] = $params['gradle_task'] ?? 'assembleDebug'; $params['notify_on_fail'] = array_key_exists('notify_on_fail', $params) ? !empty($params['notify_on_fail']) : true; @@ -27,6 +30,13 @@ $params['notify_channel'] = in_array( ['email', 'telegram', 'both'], true ) ? (string) $params['notify_channel'] : 'email'; +foreach (['ota_major', 'ota_minor', 'ota_patch'] as $k) { + if (isset($params[$k]) && $params[$k] !== '') { + $params[$k] = max(0, min(99, (int) $params[$k])); + } else { + unset($params[$k]); + } +} if (!isset($params['trigger_source'])) { $params['trigger_source'] = 'manual'; } diff --git a/public/assets/js/builder.js b/public/assets/js/builder.js index 3dbb36c..518d4a7 100644 --- a/public/assets/js/builder.js +++ b/public/assets/js/builder.js @@ -2,6 +2,63 @@ 'use strict'; var base = document.body.getAttribute('data-base-path') || ''; + function formatDuration(seconds) { + seconds = Math.max(0, Math.floor(Number(seconds) || 0)); + if (seconds < 60) return seconds + 's'; + var m = Math.floor(seconds / 60); + var s = seconds % 60; + if (m < 60) return s > 0 ? m + 'm ' + s + 's' : m + 'm'; + var h = Math.floor(m / 60); + m = m % 60; + var parts = [h + 'h']; + if (m > 0) parts.push(m + 'm'); + if (s > 0 && h < 48) parts.push(s + 's'); + return parts.join(' '); + } + + function durationLabelForBuild(build) { + if (!build) return '—'; + if (build.duration_label) return build.duration_label; + var kind = build.duration_kind || ''; + var sec = build.duration_seconds; + if (sec == null) return '—'; + var label = formatDuration(sec); + if (kind === 'elapsed') return 'Running for ' + label; + if (kind === 'queued') return 'queued · ' + label; + return label; + } + + function tickDurationFromIso(startIso, kind) { + if (!startIso) return '—'; + var start = Date.parse(startIso); + if (!start || isNaN(start)) return '—'; + var sec = Math.max(0, Math.floor((Date.now() - start) / 1000)); + var label = formatDuration(sec); + if (kind === 'elapsed') return 'Running for ' + label; + if (kind === 'queued') return 'queued · ' + label; + return label; + } + + function updateDurationCells() { + document.querySelectorAll('.build-duration-cell[data-duration-started]').forEach(function (cell) { + var kind = cell.getAttribute('data-duration-kind') || ''; + if (kind !== 'elapsed' && kind !== 'queued') return; + var start = cell.getAttribute('data-duration-started') || ''; + cell.textContent = tickDurationFromIso(start, kind); + }); + var kind = document.body.getAttribute('data-duration-kind') || ''; + if (kind === 'elapsed' || kind === 'queued') { + var start = document.body.getAttribute('data-build-started-at') || ''; + var label = tickDurationFromIso(start, kind); + var el = document.getElementById('build-duration-label'); + var detail = document.getElementById('build-duration-detail'); + if (el) el.textContent = label; + if (detail) detail.textContent = kind === 'elapsed' ? label : label; + } + } + + setInterval(updateDurationCells, 1000); + function buildStatusIconHtml(status) { var s = String(status || '').toLowerCase(); if (s === 'running' || s === 'queued') { @@ -37,6 +94,21 @@ } } } + if (build.duration_label !== undefined) { + var row2 = cell ? cell.closest('tr') : null; + if (row2) { + var durCell = row2.querySelector('.build-duration-cell'); + if (durCell) { + durCell.textContent = durationLabelForBuild(build); + if (build.duration_kind) { + durCell.setAttribute('data-duration-kind', build.duration_kind); + } + if (build.duration_started_at) { + durCell.setAttribute('data-duration-started', build.duration_started_at); + } + } + } + } } function updateBuildRow(row, build) { @@ -139,11 +211,17 @@ run_native: !!form.querySelector('[name=run_native]').checked, run_apk: !!form.querySelector('[name=run_apk]').checked, auto_ota: !!form.querySelector('[name=auto_ota]').checked, - auto_deploy: !!form.querySelector('[name=auto_deploy]').checked, + auto_deploy: !!form.querySelector('[name=auto_ota]').checked, notify_on_fail: !!form.querySelector('[name=notify_on_fail]').checked, notify_channel: fd.get('notify_channel') || 'email', trigger_source: 'manual' }; + ['ota_major', 'ota_minor', 'ota_patch'].forEach(function (k) { + var el = form.querySelector('[name=' + k + ']'); + if (el && el.value !== '') { + payload[k] = parseInt(el.value, 10); + } + }); status.textContent = 'Starting…'; fetch(base + '/api/build_trigger.php', { method: 'POST', @@ -310,6 +388,23 @@ + '') : ''; textEl.innerHTML = 'Status: ' + (j.build.status || '') + ' · Phase: ' + (j.build.phase || '') + err; + var durLabel = durationLabelForBuild(j.build); + if (durLabel !== '—') { + textEl.innerHTML += ' · ' + durLabel + ''; + } + var durDetail = document.getElementById('build-duration-detail'); + if (durDetail && j.build.duration_kind === 'final') { + durDetail.textContent = j.build.duration_label || durLabel; + } + } + if (j.build.duration_kind) { + document.body.setAttribute('data-duration-kind', j.build.duration_kind); + } + if (j.build.duration_started_at) { + document.body.setAttribute('data-build-started-at', j.build.duration_started_at); + } + if (j.build.duration_finished_at) { + document.body.setAttribute('data-build-finished-at', j.build.duration_finished_at); } document.body.setAttribute('data-build-status', j.build.status || ''); } diff --git a/src/BuildDuration.php b/src/BuildDuration.php new file mode 100644 index 0000000..aa13e46 --- /dev/null +++ b/src/BuildDuration.php @@ -0,0 +1,89 @@ + $build */ + public static function annotate(array $build, ?int $now = null): array { + $now = $now ?? time(); + $status = (string) ($build['status'] ?? ''); + $created = self::parseUtc((string) ($build['created_at'] ?? '')); + $started = self::parseUtc((string) ($build['started_at'] ?? '')); + $finished = self::parseUtc((string) ($build['finished_at'] ?? '')); + + $startTs = $started ?? $created; + $kind = 'none'; + $seconds = null; + $label = '—'; + + if (in_array($status, ['passed', 'failed', 'cancelled'], true)) { + $endTs = $finished ?? $now; + if ($startTs !== null && $endTs !== null && $endTs >= $startTs) { + $seconds = $endTs - $startTs; + $kind = 'final'; + $label = self::formatSeconds($seconds); + } + } elseif ($status === 'running') { + if ($startTs !== null) { + $seconds = max(0, $now - $startTs); + $kind = 'elapsed'; + $label = self::formatSeconds($seconds); + } + } elseif ($status === 'queued') { + if ($created !== null) { + $seconds = max(0, $now - $created); + $kind = 'queued'; + $label = 'queued · ' . self::formatSeconds($seconds); + } + } + + $build['duration_seconds'] = $seconds; + $build['duration_label'] = $label; + $build['duration_kind'] = $kind; + $build['duration_started_at'] = $started !== null + ? gmdate('Y-m-d\TH:i:s\Z', $started) + : ($created !== null ? gmdate('Y-m-d\TH:i:s\Z', $created) : null); + $build['duration_finished_at'] = $finished !== null ? gmdate('Y-m-d\TH:i:s\Z', $finished) : null; + return $build; + } + + public static function formatSeconds(int $sec): string { + if ($sec < 0) { + $sec = 0; + } + if ($sec < 60) { + return $sec . 's'; + } + $m = intdiv($sec, 60); + $s = $sec % 60; + if ($m < 60) { + return $s > 0 ? "{$m}m {$s}s" : "{$m}m"; + } + $h = intdiv($m, 60); + $m = $m % 60; + $parts = ["{$h}h"]; + if ($m > 0) { + $parts[] = "{$m}m"; + } + if ($s > 0 && $h < 48) { + $parts[] = "{$s}s"; + } + return implode(' ', $parts); + } + + private static function parseUtc(string $s): ?int { + $s = trim($s); + if ($s === '' || $s === '0000-00-00 00:00:00') { + return null; + } + $dt = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $s, new DateTimeZone('UTC')); + if ($dt === false) { + $t = strtotime($s . ' UTC'); + return $t !== false ? $t : null; + } + return $dt->getTimestamp(); + } +} diff --git a/src/BuildNotifier.php b/src/BuildNotifier.php index c1ddebc..ce34063 100644 --- a/src/BuildNotifier.php +++ b/src/BuildNotifier.php @@ -38,13 +38,19 @@ final class BuildNotifier { self::sendTelegram( $userPrefix . $subject, $bodyText, - self::telegramConfig() + self::telegramConfig(), + self::brandedQrPng($detailUrl) ); } if ($channel === 'email' || $channel === 'both') { $to = self::emailForUser($userId); if ($to !== '') { - self::sendPlainEmail($to, $subject, nl2br(h($bodyText)), self::smtpConfig()); + $html = nl2br(h($bodyText)); + $qr = self::brandedQrPng($detailUrl); + if ($qr !== null) { + $html .= '

QR

'; + } + self::sendPlainEmail($to, $subject, $html, self::smtpConfig()); } } @@ -53,19 +59,31 @@ final class BuildNotifier { } private static function notifyAdmin(string $subject, string $bodyText, string $code, string $safeError, string $detailUrl): void { + $qrPng = self::brandedQrPng($detailUrl); $tg = self::telegramConfig(); if ($tg['token'] !== '' && $tg['chat_id'] !== '') { - self::sendTelegram($subject, $bodyText, $tg); + self::sendTelegram($subject, $bodyText, $tg, $qrPng); } $admin = (string) cfg('build.notify.admin_email', 'bestcastr@gmail.com'); if ($admin !== '') { $html = '

' . h($code) . ' failed.

' . '
' . h($safeError) . '
' . '

Open build log

'; + if ($qrPng !== null) { + $b64 = base64_encode($qrPng); + $html .= '

QR

'; + } self::sendPlainEmail($admin, $subject, $html, self::smtpConfig()); } } + private static function brandedQrPng(string $url): ?string { + if (!class_exists('QrBrandedRenderer', false)) { + return null; + } + return QrBrandedRenderer::renderPng($url, 256); + } + /** @return array{token:string,chat_id:string} */ private static function telegramConfig(): array { $token = trim((string) cfg('build.notify.telegram_token', '')); @@ -165,10 +183,14 @@ final class BuildNotifier { } /** @param array{token:string,chat_id:string} $tg */ - private static function sendTelegram(string $title, string $body, array $tg): void { + private static function sendTelegram(string $title, string $body, array $tg, ?string $qrPng = null): void { if ($tg['token'] === '' || $tg['chat_id'] === '') { return; } + if ($qrPng !== null && strlen($qrPng) > 100) { + self::sendTelegramPhoto($title, $body, $tg, $qrPng); + return; + } $text = '' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n\n" . htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $payload = http_build_query([ @@ -193,6 +215,38 @@ final class BuildNotifier { ); } + /** @param array{token:string,chat_id:string} $tg */ + private static function sendTelegramPhoto(string $title, string $body, array $tg, string $qrPng): void { + $caption = '' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n\n" + . htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $boundary = 'ac' . bin2hex(random_bytes(8)); + $bodyRaw = "--{$boundary}\r\n" + . "Content-Disposition: form-data; name=\"chat_id\"\r\n\r\n{$tg['chat_id']}\r\n" + . "--{$boundary}\r\n" + . "Content-Disposition: form-data; name=\"caption\"\r\n\r\n{$caption}\r\n" + . "--{$boundary}\r\n" + . "Content-Disposition: form-data; name=\"parse_mode\"\r\n\r\nHTML\r\n" + . "--{$boundary}\r\n" + . "Content-Disposition: form-data; name=\"photo\"; filename=\"build-qr.png\"\r\n" + . "Content-Type: image/png\r\n\r\n" + . $qrPng . "\r\n" + . "--{$boundary}--\r\n"; + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: multipart/form-data; boundary={$boundary}\r\n", + 'content' => $bodyRaw, + 'timeout' => 20, + 'ignore_errors' => true, + ], + ]); + @file_get_contents( + 'https://api.telegram.org/bot' . $tg['token'] . '/sendPhoto', + false, + $ctx + ); + } + /** @param array $smtp */ private static function sendPlainEmail(string $to, string $subject, string $htmlBody, array $smtp): void { if ($to === '') { diff --git a/src/BuildRepository.php b/src/BuildRepository.php index f402236..50eb276 100644 --- a/src/BuildRepository.php +++ b/src/BuildRepository.php @@ -98,7 +98,7 @@ SQL); $stmt = Database::pdo()->prepare('SELECT * FROM builds WHERE id = ? LIMIT 1'); $stmt->execute([$id]); $row = $stmt->fetch(); - return $row ?: null; + return $row ? BuildDuration::annotate($row) : null; } public static function listRecent(int $limit = 10): array { @@ -106,7 +106,8 @@ SQL); $stmt = Database::pdo()->prepare('SELECT * FROM builds ORDER BY id DESC LIMIT ?'); $stmt->bindValue(1, $limit, PDO::PARAM_INT); $stmt->execute(); - return $stmt->fetchAll() ?: []; + $rows = $stmt->fetchAll() ?: []; + return array_map(static fn (array $row): array => BuildDuration::annotate($row), $rows); } /** @return list */ @@ -142,6 +143,8 @@ SQL); 'passed_7d' => $passed, 'failed_7d' => $failed, 'ci_version' => cfg('build.ci_version', '00.01.00.1000'), + 'next_version' => VersionAllocator::allocate([])['version_name'], + 'version_floors' => cfg('build.version', []), ]; } } diff --git a/src/BuildRunner.php b/src/BuildRunner.php index d3bb6e1..48302f2 100644 --- a/src/BuildRunner.php +++ b/src/BuildRunner.php @@ -114,6 +114,14 @@ YAML; $params['trigger_source'] = $userId !== null ? 'manual' : 'agent'; } $params['pipeline_yaml'] = $params['pipeline_yaml'] ?? self::resolvePipelineYaml($params); + $version = VersionAllocator::allocate($params); + $params['ota_major'] = $version['major']; + $params['ota_minor'] = $version['minor']; + $params['ota_patch'] = $version['patch']; + $params['ota_build'] = $version['build']; + $params['version_name'] = $version['version_name']; + $params['version_code'] = $version['version_code']; + $ciVersion = $version['ci_version']; $buildCode = self::generateBuildCode(); $id = BuildRepository::create([ 'build_code' => $buildCode, @@ -124,13 +132,17 @@ YAML; 'branch' => $params['branch'] ?? ($params['git_ref'] ?? null), 'pipeline_yaml' => $params['pipeline_yaml'] ?? null, 'params_json' => json_encode($params, JSON_UNESCAPED_SLASHES), - 'dockerfile_version' => cfg('build.ci_version', '00.01.00.1000'), + 'dockerfile_version' => $ciVersion, 'builder_id' => gethostname() ?: 'builder', 'ota_channel' => $params['ota_channel'] ?? 'staging', 'auto_ota' => !empty($params['auto_ota']), 'auto_deploy' => !empty($params['auto_deploy']), 'created_by_user_id' => $userId, ]); + BuildRepository::update($id, [ + 'version_app' => VersionAllocator::truncateVersionApp($version['version_name']), + 'version_code' => $version['version_code'], + ]); $dir = self::artifactDir($id); if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) { self::failBuild( @@ -175,6 +187,7 @@ YAML; $mobileSub = trim((string) cfg('build.mobile_subdir', 'ac-mobile-android')); $scriptsDir = trim((string) cfg('build.scripts_dir', dirname((string) cfg('build.runner_script', '')))); $dockerHome = self::dockerHomeDir(); + $artifactsRoot = rtrim((string) cfg('build.artifacts_root', '/workspace/out/builds'), '/'); $env = [ 'HOME=' . escapeshellarg($dockerHome), 'DOCKER_CONFIG=' . escapeshellarg($dockerHome . '/.docker'), @@ -186,10 +199,19 @@ YAML; 'BUILD_GIT_DEPTH=' . max(1, (int) cfg('build.git_clone_depth', 1)), 'BUILD_WORK_DIR=' . escapeshellarg($repo), 'BUILD_OUT_DIR=' . escapeshellarg($dir), + 'BUILD_ARTIFACTS_ROOT=' . escapeshellarg($artifactsRoot), + 'BUILD_CCACHE_DIR=' . escapeshellarg((string) cfg('build.cache_ccache_dir', $artifactsRoot . '/.cache/ccache')), + 'BUILD_GRADLE_DIR=' . escapeshellarg((string) cfg('build.cache_gradle_dir', $artifactsRoot . '/.cache/gradle')), + 'BUILD_KEEP_SRC_DIRS=' . max(1, (int) cfg('build.keep_src_dirs', 10)), + 'DOCKER_BIN=' . escapeshellarg(self::dockerBinary()), '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')), + 'ANDROIDCAST_CI_VERSION=' . escapeshellarg($ciVersion), + 'OTA_MAJOR=' . (int) $version['major'], + 'OTA_MINOR=' . (int) $version['minor'], + 'OTA_PATCH=' . (int) $version['patch'], + 'OTA_BUILD=' . (int) $version['build'], 'GIT_REF=' . escapeshellarg((string) ($params['git_ref'] ?? '')), 'GIT_SHA=' . escapeshellarg((string) ($params['git_sha'] ?? '')), 'GIT_REMOTE=' . escapeshellarg($gitRemote), @@ -481,7 +503,12 @@ YAML; $dir = self::artifactDir($id); $logPath = (string) ($row['log_path'] ?? ($dir . '/build.log')); if (is_file($dir . '/android_cast-latest.apk')) { - self::finalizeFromArtifacts($id); + try { + self::finalizeFromArtifacts($id); + } catch (Throwable $e) { + error_log('[BuildRunner] finalizeFromArtifacts #' . $id . ': ' . $e->getMessage()); + self::failBuild($id, 'Finalize failed: ' . $e->getMessage(), $logPath); + } continue; } $tail = ''; @@ -504,7 +531,12 @@ YAML; if (str_contains($tail, 'Cannot connect to the Docker daemon') || !self::dockerAvailable()) { self::failBuild($id, 'Docker daemon not available', $logPath); } elseif (str_contains($tail, '[runner] finished')) { - self::finalizeFromArtifacts($id); + try { + self::finalizeFromArtifacts($id); + } catch (Throwable $e) { + error_log('[BuildRunner] finalizeFromArtifacts #' . $id . ': ' . $e->getMessage()); + self::failBuild($id, 'Finalize failed: ' . $e->getMessage(), $logPath); + } } elseif ($tail !== '') { $hint = self::summarizeLogFailure($logPath); $msg = $hint !== '' ? $hint : 'Build runner exited unexpectedly'; @@ -619,19 +651,41 @@ YAML; $artifacts['ota'] = 'ota/v0'; } $versionApp = null; + $versionCode = null; if (is_file($infoPath)) { $info = json_decode((string) file_get_contents($infoPath), true); if (is_array($info)) { - $versionApp = $info['gitSha'] ?? null; + $versionApp = $info['versionName'] ?? $info['version_name'] ?? null; + if (isset($info['versionCode'])) { + $versionCode = (int) $info['versionCode']; + } elseif (isset($info['version_code'])) { + $versionCode = (int) $info['version_code']; + } + } + } + $build = BuildRepository::getById($buildId); + if ($versionApp === null && $build) { + $params = json_decode((string) ($build['params_json'] ?? '{}'), true); + if (is_array($params)) { + $versionApp = $params['version_name'] ?? null; + if ($versionCode === null && isset($params['version_code'])) { + $versionCode = (int) $params['version_code']; + } + } + if ($versionApp === null && !empty($build['version_app'])) { + $versionApp = (string) $build['version_app']; } } $update = [ 'status' => $status, 'phase' => 'done', 'artifacts_json' => json_encode($artifacts, JSON_UNESCAPED_SLASHES), - 'version_app' => $versionApp, + 'version_app' => VersionAllocator::truncateVersionApp($versionApp), 'finished_at' => gmdate('Y-m-d H:i:s'), ]; + if ($versionCode !== null && $versionCode > 0) { + $update['version_code'] = $versionCode; + } if ($status === 'failed') { $update['error_message'] = substr( BuildErrorSanitizer::sanitize('Build finished without APK artifact'), @@ -655,7 +709,11 @@ YAML; } if (!empty($artifacts['ota']) && cfg('build.ota_mount')) { $build = BuildRepository::getById($buildId); - if (!empty($build['auto_deploy'])) { + $params = json_decode((string) ($build['params_json'] ?? '{}'), true); + if (!is_array($params)) { + $params = []; + } + if (!empty($build['auto_deploy']) || !empty($params['auto_ota'])) { self::publishOta($buildId, $dir); } } @@ -664,16 +722,24 @@ YAML; private static function publishOta(int $buildId, string $dir): void { $mount = rtrim((string) cfg('build.ota_mount', ''), '/'); if ($mount === '' || !is_dir($mount)) { + error_log('[BuildRunner] publishOta #' . $buildId . ': ota_mount missing or not a directory'); return; } - $src = $dir . '/ota/v0'; - if (!is_dir($src)) { + $src = self::resolveOtaSourceDir($dir); + if ($src === null) { + error_log('[BuildRunner] publishOta #' . $buildId . ': no OTA tree under ' . $dir); return; } $build = BuildRepository::getById($buildId); - $channel = (string) ($build['ota_channel'] ?? 'staging'); + $channel = self::normalizeOtaChannel((string) ($build['ota_channel'] ?? 'staging')); $dest = $mount . '/v0'; - shell_exec('mkdir -p ' . escapeshellarg($dest) . ' && cp -a ' . escapeshellarg($src . '/.') . ' ' . escapeshellarg($dest . '/')); + $cmd = 'mkdir -p ' . escapeshellarg($dest) + . ' && cp -a ' . escapeshellarg($src . '/.') . ' ' . escapeshellarg($dest . '/'); + $out = shell_exec($cmd . ' 2>&1'); + if (!is_dir($dest . '/ota')) { + error_log('[BuildRunner] publishOta #' . $buildId . ' failed: ' . trim((string) $out)); + return; + } $channelJson = $src . '/ota/channel/' . $channel . '.json'; if (is_file($channelJson)) { shell_exec( @@ -684,10 +750,29 @@ YAML; . ' ' . escapeshellarg($dest . '/ota/channel/' . $channel . '.json') ); + } else { + error_log('[BuildRunner] publishOta #' . $buildId . ': missing channel json ' . $channelJson); } self::publishBrowserApk($src, $channel); } + private static function normalizeOtaChannel(string $channel): string { + $channel = strtolower(trim($channel)); + if (in_array($channel, ['prod', 'production', 'release'], true)) { + return 'stable'; + } + return $channel !== '' ? $channel : 'staging'; + } + + private static function resolveOtaSourceDir(string $dir): ?string { + foreach ([$dir . '/ota/v0', $dir . '/ota-publish/v0'] as $candidate) { + if (is_dir($candidate . '/ota')) { + return $candidate; + } + } + return null; + } + /** 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', ''), '/'); diff --git a/src/VersionAllocator.php b/src/VersionAllocator.php new file mode 100644 index 0000000..48a835e --- /dev/null +++ b/src/VersionAllocator.php @@ -0,0 +1,133 @@ + $last['major'] ? $floorMinor : max($floorMinor, $last['minor']); + $minor = self::clampComponent(max( + $minorFloor, + (int) ($params['ota_minor'] ?? $minorFloor) + )); + $patch = self::clampComponent(max( + $floorPatch, + (int) ($params['ota_patch'] ?? $floorPatch), + $last['patch'] + )); + + $nextBuild = max($floorBuild, $last['build'] + 1); + if (isset($params['ota_build']) && (int) $params['ota_build'] > 0) { + $nextBuild = max($nextBuild, (int) $params['ota_build']); + } + $build = self::clampComponent($nextBuild); + + $versionCode = $major * 10000 + $minor * 100 + $build; + $versionName = sprintf('%02d.%02d.%02d.%04d', $major, $minor, $patch, $build); + + return [ + 'major' => $major, + 'minor' => $minor, + 'patch' => $patch, + 'build' => $build, + 'version_code' => $versionCode, + 'version_name' => $versionName, + 'ci_version' => $versionName, + ]; + } + + /** @return array{major:int,minor:int,patch:int,build:int,version_code:int} */ + public static function lastPassedVersion(): array { + $defaults = ['major' => 0, 'minor' => 1, 'patch' => 0, 'build' => 0, 'version_code' => 100]; + BuildRepository::ensureSchema(); + $pdo = Database::pdo(); + $stmt = $pdo->query( + "SELECT version_app, version_code FROM builds + WHERE status = 'passed' AND (version_code IS NOT NULL OR version_app IS NOT NULL) + ORDER BY id DESC LIMIT 1" + ); + $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : false; + if (!$row) { + return $defaults; + } + $parsed = self::parseVersionString((string) ($row['version_app'] ?? '')); + if ($parsed !== null) { + return $parsed; + } + $code = (int) ($row['version_code'] ?? 0); + if ($code > 0) { + return [ + 'major' => intdiv($code, 10000), + 'minor' => intdiv($code % 10000, 100), + 'patch' => 0, + 'build' => $code % 100, + 'version_code' => $code, + ]; + } + return $defaults; + } + + /** @return ?array{major:int,minor:int,patch:int,build:int,version_code:int} */ + public static function parseVersionString(string $s): ?array { + $s = trim($s); + if ($s === '') { + return null; + } + if (preg_match('/^(\d{1,2})\.(\d{1,2})\.(\d{1,2})\.(\d{1,4})$/', $s, $m)) { + $major = self::clampComponent((int) $m[1]); + $minor = self::clampComponent((int) $m[2]); + $patch = self::clampComponent((int) $m[3]); + $build = self::clampComponent((int) $m[4]); + return [ + 'major' => $major, + 'minor' => $minor, + 'patch' => $patch, + 'build' => $build, + 'version_code' => $major * 10000 + $minor * 100 + $build, + ]; + } + if (preg_match('/^(\d+)\.(\d+)\.(\d+)$/', $s, $m)) { + $major = self::clampComponent((int) $m[1]); + $minor = self::clampComponent((int) $m[2]); + $build = self::clampComponent((int) $m[3]); + return [ + 'major' => $major, + 'minor' => $minor, + 'patch' => 0, + 'build' => $build, + 'version_code' => $major * 10000 + $minor * 100 + $build, + ]; + } + return null; + } + + public static function truncateVersionApp(?string $s): ?string { + if ($s === null || $s === '') { + return null; + } + return mb_substr($s, 0, 32); + } + + private static function clampComponent(int $v): int { + return max(0, min(self::MAX_COMPONENT, $v)); + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php index 24e5993..15e04c7 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -74,7 +74,22 @@ foreach ([ require_once $crashSrc . '/' . $f; } +$qrPaths = [ + $crashSrc . '/QrBrandedRenderer.php', + dirname($crashSrc) . '/../platform-php/src/QrBrandedRenderer.php', + '/var/www/ac/workspace/ac-platform-php/src/QrBrandedRenderer.php', + '/var/www/ac/composed/backend/src/QrBrandedRenderer.php', +]; +foreach ($qrPaths as $qrFile) { + if (is_file($qrFile)) { + require_once $qrFile; + break; + } +} + require_once __DIR__ . '/BuildRepository.php'; +require_once __DIR__ . '/VersionAllocator.php'; +require_once __DIR__ . '/BuildDuration.php'; require_once __DIR__ . '/BuildErrorSanitizer.php'; require_once __DIR__ . '/BuildNotifier.php'; require_once __DIR__ . '/GiteaCiStatus.php'; diff --git a/views/build_detail.php b/views/build_detail.php index 684598f..775b23c 100644 --- a/views/build_detail.php +++ b/views/build_detail.php @@ -1,4 +1,5 @@ Status: · Phase: + + · Running for Took + + · + · @@ -94,6 +104,18 @@ docker exec -it bash
Git SHA
OTA channel
Gradle task
+
Started
UTC
+
Finished
UTC
+
Duration
diff --git a/views/home.php b/views/home.php index e99e86a..ec2d537 100644 --- a/views/home.php +++ b/views/home.php @@ -7,6 +7,7 @@
  • Docker:
  • CI image version:
  • +
  • Next build version: (build # auto-increments)
  • Running builds:
  • Passed (7d): · Failed (7d):
@@ -25,12 +26,18 @@ +
+ Version (AA.BB.CC.DDDD — build counter is automatic) + + + +
- - + +
@@ -57,6 +64,7 @@ Phase Branch/ref Channel + Duration Created @@ -82,6 +90,7 @@ + diff --git a/views/layout.php b/views/layout.php index 0b54825..c16af05 100644 --- a/views/layout.php +++ b/views/layout.php @@ -29,7 +29,10 @@ $navUser = is_array($user) ? trim((string) ($user['username'] ?? '')) : ''; - > + + + + >
$projectBase,