mirror of
git://f0xx.org/ac/ac-ms-build
synced 2026-08-12 18:13:16 +03:00
Sync build runner OTA publish and version allocation with builder.
Branded QR in notifications and VersionAllocator for monotonic CI versions.
This commit is contained in:
@@ -38,13 +38,19 @@ final class BuildNotifier {
|
|||||||
self::sendTelegram(
|
self::sendTelegram(
|
||||||
$userPrefix . $subject,
|
$userPrefix . $subject,
|
||||||
$bodyText,
|
$bodyText,
|
||||||
self::telegramConfig()
|
self::telegramConfig(),
|
||||||
|
self::brandedQrPng($detailUrl)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if ($channel === 'email' || $channel === 'both') {
|
if ($channel === 'email' || $channel === 'both') {
|
||||||
$to = self::emailForUser($userId);
|
$to = self::emailForUser($userId);
|
||||||
if ($to !== '') {
|
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 {
|
private static function notifyAdmin(string $subject, string $bodyText, string $code, string $safeError, string $detailUrl): void {
|
||||||
|
$qrPng = self::brandedQrPng($detailUrl);
|
||||||
$tg = self::telegramConfig();
|
$tg = self::telegramConfig();
|
||||||
if ($tg['token'] !== '' && $tg['chat_id'] !== '') {
|
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');
|
$admin = (string) cfg('build.notify.admin_email', 'bestcastr@gmail.com');
|
||||||
if ($admin !== '') {
|
if ($admin !== '') {
|
||||||
$html = '<p><strong>' . h($code) . '</strong> failed.</p>'
|
$html = '<p><strong>' . h($code) . '</strong> failed.</p>'
|
||||||
. '<pre style="white-space:pre-wrap">' . h($safeError) . '</pre>'
|
. '<pre style="white-space:pre-wrap">' . h($safeError) . '</pre>'
|
||||||
. '<p><a href="' . h($detailUrl) . '">Open build log</a></p>';
|
. '<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());
|
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} */
|
/** @return array{token:string,chat_id:string} */
|
||||||
private static function telegramConfig(): array {
|
private static function telegramConfig(): array {
|
||||||
$token = trim((string) cfg('build.notify.telegram_token', ''));
|
$token = trim((string) cfg('build.notify.telegram_token', ''));
|
||||||
@@ -165,10 +183,14 @@ final class BuildNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param array{token:string,chat_id:string} $tg */
|
/** @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'] === '') {
|
if ($tg['token'] === '' || $tg['chat_id'] === '') {
|
||||||
return;
|
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"
|
$text = '<b>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</b>\n\n"
|
||||||
. htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
. htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
$payload = http_build_query([
|
$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 */
|
/** @param array<string, mixed> $smtp */
|
||||||
private static function sendPlainEmail(string $to, string $subject, string $htmlBody, array $smtp): void {
|
private static function sendPlainEmail(string $to, string $subject, string $htmlBody, array $smtp): void {
|
||||||
if ($to === '') {
|
if ($to === '') {
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ YAML;
|
|||||||
$params['trigger_source'] = $userId !== null ? 'manual' : 'agent';
|
$params['trigger_source'] = $userId !== null ? 'manual' : 'agent';
|
||||||
}
|
}
|
||||||
$params['pipeline_yaml'] = $params['pipeline_yaml'] ?? self::resolvePipelineYaml($params);
|
$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();
|
$buildCode = self::generateBuildCode();
|
||||||
$id = BuildRepository::create([
|
$id = BuildRepository::create([
|
||||||
'build_code' => $buildCode,
|
'build_code' => $buildCode,
|
||||||
@@ -124,13 +132,17 @@ YAML;
|
|||||||
'branch' => $params['branch'] ?? ($params['git_ref'] ?? null),
|
'branch' => $params['branch'] ?? ($params['git_ref'] ?? null),
|
||||||
'pipeline_yaml' => $params['pipeline_yaml'] ?? null,
|
'pipeline_yaml' => $params['pipeline_yaml'] ?? null,
|
||||||
'params_json' => json_encode($params, JSON_UNESCAPED_SLASHES),
|
'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',
|
'builder_id' => gethostname() ?: 'builder',
|
||||||
'ota_channel' => $params['ota_channel'] ?? 'staging',
|
'ota_channel' => $params['ota_channel'] ?? 'staging',
|
||||||
'auto_ota' => !empty($params['auto_ota']),
|
'auto_ota' => !empty($params['auto_ota']),
|
||||||
'auto_deploy' => !empty($params['auto_deploy']),
|
'auto_deploy' => !empty($params['auto_deploy']),
|
||||||
'created_by_user_id' => $userId,
|
'created_by_user_id' => $userId,
|
||||||
]);
|
]);
|
||||||
|
BuildRepository::update($id, [
|
||||||
|
'version_app' => VersionAllocator::truncateVersionApp($version['version_name']),
|
||||||
|
'version_code' => $version['version_code'],
|
||||||
|
]);
|
||||||
$dir = self::artifactDir($id);
|
$dir = self::artifactDir($id);
|
||||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||||
self::failBuild(
|
self::failBuild(
|
||||||
@@ -189,7 +201,11 @@ YAML;
|
|||||||
'BUILD_MOBILE_SUBDIR=' . escapeshellarg($mobileSub),
|
'BUILD_MOBILE_SUBDIR=' . escapeshellarg($mobileSub),
|
||||||
'BUILD_SCRIPTS_DIR=' . escapeshellarg($scriptsDir !== '' ? $scriptsDir : dirname($runner)),
|
'BUILD_SCRIPTS_DIR=' . escapeshellarg($scriptsDir !== '' ? $scriptsDir : dirname($runner)),
|
||||||
'BUILD_DOCKERFILE=' . escapeshellarg((string) cfg('build.dockerfile', '')),
|
'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_REF=' . escapeshellarg((string) ($params['git_ref'] ?? '')),
|
||||||
'GIT_SHA=' . escapeshellarg((string) ($params['git_sha'] ?? '')),
|
'GIT_SHA=' . escapeshellarg((string) ($params['git_sha'] ?? '')),
|
||||||
'GIT_REMOTE=' . escapeshellarg($gitRemote),
|
'GIT_REMOTE=' . escapeshellarg($gitRemote),
|
||||||
@@ -257,7 +273,7 @@ YAML;
|
|||||||
if ($after === '' || str_starts_with($after, '0000000')) {
|
if ($after === '' || str_starts_with($after, '0000000')) {
|
||||||
throw new InvalidArgumentException('empty_or_delete_push');
|
throw new InvalidArgumentException('empty_or_delete_push');
|
||||||
}
|
}
|
||||||
$branch = str_starts_with($ref, 'refs/heads/') ? substr($ref, 11) : $ref;
|
$branch = str_starts_with($ref, 'refs/heads/') ? substr($ref, 13) : $ref;
|
||||||
if ($branch === '') {
|
if ($branch === '') {
|
||||||
throw new InvalidArgumentException('missing_branch');
|
throw new InvalidArgumentException('missing_branch');
|
||||||
}
|
}
|
||||||
@@ -481,7 +497,12 @@ YAML;
|
|||||||
$dir = self::artifactDir($id);
|
$dir = self::artifactDir($id);
|
||||||
$logPath = (string) ($row['log_path'] ?? ($dir . '/build.log'));
|
$logPath = (string) ($row['log_path'] ?? ($dir . '/build.log'));
|
||||||
if (is_file($dir . '/android_cast-latest.apk')) {
|
if (is_file($dir . '/android_cast-latest.apk')) {
|
||||||
|
try {
|
||||||
self::finalizeFromArtifacts($id);
|
self::finalizeFromArtifacts($id);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('[BuildRunner] finalizeFromArtifacts #' . $id . ': ' . $e->getMessage());
|
||||||
|
self::failBuild($id, 'Finalize failed: ' . $e->getMessage(), $logPath);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$tail = '';
|
$tail = '';
|
||||||
@@ -504,7 +525,12 @@ YAML;
|
|||||||
if (str_contains($tail, 'Cannot connect to the Docker daemon') || !self::dockerAvailable()) {
|
if (str_contains($tail, 'Cannot connect to the Docker daemon') || !self::dockerAvailable()) {
|
||||||
self::failBuild($id, 'Docker daemon not available', $logPath);
|
self::failBuild($id, 'Docker daemon not available', $logPath);
|
||||||
} elseif (str_contains($tail, '[runner] finished')) {
|
} elseif (str_contains($tail, '[runner] finished')) {
|
||||||
|
try {
|
||||||
self::finalizeFromArtifacts($id);
|
self::finalizeFromArtifacts($id);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('[BuildRunner] finalizeFromArtifacts #' . $id . ': ' . $e->getMessage());
|
||||||
|
self::failBuild($id, 'Finalize failed: ' . $e->getMessage(), $logPath);
|
||||||
|
}
|
||||||
} elseif ($tail !== '') {
|
} elseif ($tail !== '') {
|
||||||
$hint = self::summarizeLogFailure($logPath);
|
$hint = self::summarizeLogFailure($logPath);
|
||||||
$msg = $hint !== '' ? $hint : 'Build runner exited unexpectedly';
|
$msg = $hint !== '' ? $hint : 'Build runner exited unexpectedly';
|
||||||
@@ -619,19 +645,41 @@ YAML;
|
|||||||
$artifacts['ota'] = 'ota/v0';
|
$artifacts['ota'] = 'ota/v0';
|
||||||
}
|
}
|
||||||
$versionApp = null;
|
$versionApp = null;
|
||||||
|
$versionCode = null;
|
||||||
if (is_file($infoPath)) {
|
if (is_file($infoPath)) {
|
||||||
$info = json_decode((string) file_get_contents($infoPath), true);
|
$info = json_decode((string) file_get_contents($infoPath), true);
|
||||||
if (is_array($info)) {
|
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 = [
|
$update = [
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'phase' => 'done',
|
'phase' => 'done',
|
||||||
'artifacts_json' => json_encode($artifacts, JSON_UNESCAPED_SLASHES),
|
'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'),
|
'finished_at' => gmdate('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
|
if ($versionCode !== null && $versionCode > 0) {
|
||||||
|
$update['version_code'] = $versionCode;
|
||||||
|
}
|
||||||
if ($status === 'failed') {
|
if ($status === 'failed') {
|
||||||
$update['error_message'] = substr(
|
$update['error_message'] = substr(
|
||||||
BuildErrorSanitizer::sanitize('Build finished without APK artifact'),
|
BuildErrorSanitizer::sanitize('Build finished without APK artifact'),
|
||||||
@@ -655,7 +703,11 @@ YAML;
|
|||||||
}
|
}
|
||||||
if (!empty($artifacts['ota']) && cfg('build.ota_mount')) {
|
if (!empty($artifacts['ota']) && cfg('build.ota_mount')) {
|
||||||
$build = BuildRepository::getById($buildId);
|
$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);
|
self::publishOta($buildId, $dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -664,16 +716,24 @@ YAML;
|
|||||||
private static function publishOta(int $buildId, string $dir): void {
|
private static function publishOta(int $buildId, string $dir): void {
|
||||||
$mount = rtrim((string) cfg('build.ota_mount', ''), '/');
|
$mount = rtrim((string) cfg('build.ota_mount', ''), '/');
|
||||||
if ($mount === '' || !is_dir($mount)) {
|
if ($mount === '' || !is_dir($mount)) {
|
||||||
|
error_log('[BuildRunner] publishOta #' . $buildId . ': ota_mount missing or not a directory');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$src = $dir . '/ota/v0';
|
$src = self::resolveOtaSourceDir($dir);
|
||||||
if (!is_dir($src)) {
|
if ($src === null) {
|
||||||
|
error_log('[BuildRunner] publishOta #' . $buildId . ': no OTA tree under ' . $dir);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$build = BuildRepository::getById($buildId);
|
$build = BuildRepository::getById($buildId);
|
||||||
$channel = (string) ($build['ota_channel'] ?? 'staging');
|
$channel = self::normalizeOtaChannel((string) ($build['ota_channel'] ?? 'staging'));
|
||||||
$dest = $mount . '/v0';
|
$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';
|
$channelJson = $src . '/ota/channel/' . $channel . '.json';
|
||||||
if (is_file($channelJson)) {
|
if (is_file($channelJson)) {
|
||||||
shell_exec(
|
shell_exec(
|
||||||
@@ -684,10 +744,29 @@ YAML;
|
|||||||
. ' '
|
. ' '
|
||||||
. escapeshellarg($dest . '/ota/channel/' . $channel . '.json')
|
. escapeshellarg($dest . '/ota/channel/' . $channel . '.json')
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
error_log('[BuildRunner] publishOta #' . $buildId . ': missing channel json ' . $channelJson);
|
||||||
}
|
}
|
||||||
self::publishBrowserApk($src, $channel);
|
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. */
|
/** Copy installable .apk to hub /v0/downloads/ for browser sideload. */
|
||||||
private static function publishBrowserApk(string $otaV0Dir, string $channel): void {
|
private static function publishBrowserApk(string $otaV0Dir, string $channel): void {
|
||||||
$downloads = rtrim((string) cfg('build.downloads_mount', ''), '/');
|
$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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user