mirror of
git://f0xx.org/ac/ac-be-builder
synced 2026-08-12 18:12:50 +03:00
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.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
+ '</span>') : '';
|
||||
textEl.innerHTML = 'Status: <strong>' + (j.build.status || '') + '</strong> · Phase: '
|
||||
+ (j.build.phase || '') + err;
|
||||
var durLabel = durationLabelForBuild(j.build);
|
||||
if (durLabel !== '—') {
|
||||
textEl.innerHTML += ' · <span id="build-duration-label">' + durLabel + '</span>';
|
||||
}
|
||||
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 || '');
|
||||
}
|
||||
|
||||
89
src/BuildDuration.php
Normal file
89
src/BuildDuration.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Human-readable build elapsed / final duration from started_at / finished_at. */
|
||||
final class BuildDuration {
|
||||
private function __construct() {
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $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();
|
||||
}
|
||||
}
|
||||
@@ -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 .= '<p><img src="data:image/png;base64,' . base64_encode($qr) . '" alt="QR" width="200" height="200"></p>';
|
||||
}
|
||||
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 = '<p><strong>' . h($code) . '</strong> failed.</p>'
|
||||
. '<pre style="white-space:pre-wrap">' . h($safeError) . '</pre>'
|
||||
. '<p><a href="' . h($detailUrl) . '">Open build log</a></p>';
|
||||
if ($qrPng !== null) {
|
||||
$b64 = base64_encode($qrPng);
|
||||
$html .= '<p><img src="data:image/png;base64,' . $b64 . '" alt="QR" width="200" height="200"></p>';
|
||||
}
|
||||
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 = '<b>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</b>\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 = '<b>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</b>\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<string, mixed> $smtp */
|
||||
private static function sendPlainEmail(string $to, string $subject, string $htmlBody, array $smtp): void {
|
||||
if ($to === '') {
|
||||
|
||||
@@ -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<int> */
|
||||
@@ -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', []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
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')) {
|
||||
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', ''), '/');
|
||||
|
||||
133
src/VersionAllocator.php
Normal file
133
src/VersionAllocator.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Monotonic OTA version allocation for builder pipeline (AA.BB.CC.DDDD display). */
|
||||
final class VersionAllocator {
|
||||
private const MAX_COMPONENT = 99;
|
||||
|
||||
/** @return array{major:int,minor:int,patch:int,build:int,version_code:int,version_name:string,ci_version:string} */
|
||||
public static function allocate(array $params): array {
|
||||
$cfg = cfg('build.version', []);
|
||||
if (!is_array($cfg)) {
|
||||
$cfg = [];
|
||||
}
|
||||
|
||||
$floorMajor = self::clampComponent((int) ($cfg['major'] ?? 0));
|
||||
$floorMinor = self::clampComponent((int) ($cfg['minor'] ?? 1));
|
||||
$floorPatch = self::clampComponent((int) ($cfg['patch'] ?? 0));
|
||||
$floorBuild = max(1, (int) ($cfg['build_floor'] ?? 1));
|
||||
|
||||
$last = self::lastPassedVersion();
|
||||
|
||||
$major = self::clampComponent(max(
|
||||
$floorMajor,
|
||||
(int) ($params['ota_major'] ?? $floorMajor),
|
||||
$last['major']
|
||||
));
|
||||
$minorFloor = $major > $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));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php declare(strict_types=1);
|
||||
$build = BuildDuration::annotate($build);
|
||||
$artifacts = json_decode($build['artifacts_json'] ?? '{}', true) ?: [];
|
||||
$params = json_decode($build['params_json'] ?? '{}', true) ?: [];
|
||||
$status = (string) ($build['status'] ?? '');
|
||||
@@ -24,6 +25,15 @@ $stepLabels = [
|
||||
<p class="muted build-status-line" id="build-status-line">
|
||||
<span id="build-status-icon"><?= build_status_icon($status) ?></span>
|
||||
<span id="build-status-text">Status: <strong><?= h($status) ?></strong> · Phase: <?= h($phase) ?>
|
||||
<?php if (($build['duration_label'] ?? '—') !== '—'): ?>
|
||||
· <span id="build-duration-label" data-duration-kind="<?= h((string) ($build['duration_kind'] ?? '')) ?>"><?php
|
||||
$dk = (string) ($build['duration_kind'] ?? '');
|
||||
if ($dk === 'elapsed'): ?>Running for <?= h($build['duration_label']) ?><?php
|
||||
elseif ($dk === 'final'): ?>Took <?= h($build['duration_label']) ?><?php
|
||||
else: ?><?= h($build['duration_label']) ?><?php endif; ?></span>
|
||||
<?php else: ?>
|
||||
· <span id="build-duration-label" data-duration-kind="none" hidden></span>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($build['error_message'])): ?>
|
||||
· <span class="error-text" id="build-error-message"><?= h($build['error_message']) ?></span>
|
||||
<?php endif; ?>
|
||||
@@ -94,6 +104,18 @@ docker exec -it <?= h($sshContainer) ?> bash</pre>
|
||||
<dt>Git SHA</dt><dd><code><?= h($build['git_sha'] ?? '—') ?></code></dd>
|
||||
<dt>OTA channel</dt><dd><?= h($build['ota_channel'] ?? '—') ?></dd>
|
||||
<dt>Gradle task</dt><dd><code><?= h($params['gradle_task'] ?? '—') ?></code></dd>
|
||||
<dt>Started</dt><dd><?= h($build['started_at'] ?? '—') ?> UTC</dd>
|
||||
<dt>Finished</dt><dd><?= h($build['finished_at'] ?? '—') ?> UTC</dd>
|
||||
<dt>Duration</dt><dd id="build-duration-detail"><?php
|
||||
$dk = (string) ($build['duration_kind'] ?? '');
|
||||
if ($dk === 'elapsed') {
|
||||
echo 'Running for ' . h($build['duration_label']);
|
||||
} elseif ($dk === 'final') {
|
||||
echo h($build['duration_label']);
|
||||
} else {
|
||||
echo h($build['duration_label']);
|
||||
}
|
||||
?></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<ul id="build-health-list">
|
||||
<li>Docker: <strong id="build-health-docker"><?= h($health['docker'] ?? 'unknown') ?></strong></li>
|
||||
<li>CI image version: <code><?= h($health['ci_version'] ?? '') ?></code></li>
|
||||
<li>Next build version: <code id="build-next-version"><?= h($health['next_version'] ?? '') ?></code> <span class="muted">(build # auto-increments)</span></li>
|
||||
<li>Running builds: <span id="build-health-running"><?= (int) ($health['running'] ?? 0) ?></span></li>
|
||||
<li>Passed (7d): <?= (int) ($health['passed_7d'] ?? 0) ?> · Failed (7d): <?= (int) ($health['failed_7d'] ?? 0) ?></li>
|
||||
</ul>
|
||||
@@ -25,12 +26,18 @@
|
||||
<option value="stable">stable / prod</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<fieldset class="build-form-grid" style="margin-top:10px;border:none;padding:0">
|
||||
<legend class="muted" style="margin-bottom:6px">Version (AA.BB.CC.DDDD — build counter is automatic)</legend>
|
||||
<label>Major (AA)<input name="ota_major" type="number" min="0" max="99" placeholder="auto" title="Leave empty for pipeline default"></label>
|
||||
<label>Minor (BB)<input name="ota_minor" type="number" min="0" max="99" placeholder="auto"></label>
|
||||
<label>Patch (CC)<input name="ota_patch" type="number" min="0" max="99" placeholder="auto"></label>
|
||||
</fieldset>
|
||||
<div class="build-form-checks">
|
||||
<label><input type="checkbox" name="run_tests" checked> Unit tests</label>
|
||||
<label><input type="checkbox" name="run_native" checked> Native codecs</label>
|
||||
<label><input type="checkbox" name="run_apk" checked> APK output</label>
|
||||
<label><input type="checkbox" name="auto_ota"> Create OTA artifacts</label>
|
||||
<label><input type="checkbox" name="auto_deploy"> Publish OTA to mount</label>
|
||||
<label><input type="checkbox" name="auto_ota" id="build-auto-ota"> Create OTA artifacts & publish</label>
|
||||
<input type="hidden" name="auto_deploy" value="0">
|
||||
</div>
|
||||
<div class="build-form-grid" style="margin-top:10px">
|
||||
<label><input type="checkbox" name="notify_on_fail" checked> Alert me if this build fails</label>
|
||||
@@ -57,6 +64,7 @@
|
||||
<th>Phase</th>
|
||||
<th>Branch/ref</th>
|
||||
<th>Channel</th>
|
||||
<th>Duration</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -82,6 +90,7 @@
|
||||
<td class="build-phase-cell" data-build-phase-cell="<?= (int) $b['id'] ?>"><?= h($b['phase']) ?></td>
|
||||
<td><?= h($b['git_ref'] ?? $b['branch'] ?? '—') ?></td>
|
||||
<td><?= h($b['ota_channel'] ?? '—') ?></td>
|
||||
<td class="build-duration-cell" data-build-id="<?= (int) $b['id'] ?>" data-duration-kind="<?= h((string) ($b['duration_kind'] ?? '')) ?>" data-duration-started="<?= h((string) ($b['duration_started_at'] ?? '')) ?>"><?= h($b['duration_label'] ?? '—') ?></td>
|
||||
<td><?= h($b['created_at'] ?? '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -29,7 +29,10 @@ $navUser = is_array($user) ? trim((string) ($user['username'] ?? '')) : '';
|
||||
<body data-base-path="<?= h(Auth::basePath()) ?>"
|
||||
data-view="<?= h($view ?? 'home') ?>"
|
||||
<?= (($view ?? '') === 'build' && !empty($build['id'])) ? ' data-build-id="' . (int) $build['id'] . '"' : '' ?>
|
||||
<?= (($view ?? '') === 'build' && !empty($build['status'])) ? ' data-build-status="' . h((string) $build['status']) . '"' : '' ?>>
|
||||
<?= (($view ?? '') === 'build' && !empty($build['status'])) ? ' data-build-status="' . h((string) $build['status']) . '"' : '' ?>
|
||||
<?= (($view ?? '') === 'build' && !empty($build['duration_started_at'])) ? ' data-build-started-at="' . h((string) $build['duration_started_at']) . '"' : '' ?>
|
||||
<?= (($view ?? '') === 'build' && !empty($build['duration_finished_at'])) ? ' data-build-finished-at="' . h((string) $build['duration_finished_at']) . '"' : '' ?>
|
||||
<?= (($view ?? '') === 'build' && !empty($build['duration_kind'])) ? ' data-duration-kind="' . h((string) $build['duration_kind']) . '"' : '' ?>>
|
||||
<div class="shell">
|
||||
<?php platform_render_nav_shell([
|
||||
'project_base' => $projectBase,
|
||||
|
||||
Reference in New Issue
Block a user