commit 5e3e5939a89a85a7669803eb3c64e277c4fdda42 Author: Anton Afanasyeu Date: Tue Jun 23 12:29:38 2026 +0200 initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..a911d70 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ac-be-builder + +Builder console. Remote: `git://f0xx.org/ac/ac-be-builder` diff --git a/config/config.example.php b/config/config.example.php new file mode 100644 index 0000000..953a1c2 --- /dev/null +++ b/config/config.example.php @@ -0,0 +1,47 @@ + 'Android Cast Builder', + 'base_path' => '/app/androidcast_project/build', + 'session_name' => 'ac_crash_sess', + 'session_cookie_path' => '/app/androidcast_project', + 'db' => [ + 'driver' => 'mysql', + 'sqlite_path' => __DIR__ . '/../../crash_reporter/backend/data/crashes.sqlite', + 'mysql' => [ + 'host' => 'mariadb', + 'port' => 3306, + 'socket' => '', + 'database' => 'androidcast_crashes', + 'username' => 'androidcast', + 'password' => 'androidcast', + 'charset' => 'utf8mb4', + ], + ], + 'debug' => true, + 'rbac' => [ + 'default_company_slug' => 'default', + 'default_company_id' => 1, + ], + 'build' => [ + 'docker_image' => 'androidcast-ci:latest', + 'docker_bin' => '/usr/bin/docker', + 'ci_version' => '00.01.00.1000', + 'repo_root' => '/workspace', + 'artifacts_root' => '/workspace/out/builds', + 'ota_mount' => '/workspace/orchestration/runtime/ota-artifacts', + 'ota_base_url' => 'http://localhost:8080', + 'runner_script' => '/workspace/scripts/docker-build-runner.sh', + // Per-build shallow clone to out/builds/{id}/src (safe for parallel jobs) + 'isolate_source' => true, + 'git_clone_depth' => 1, + // Repo copy preferred; generated YAML used if missing on builder host + 'pipeline_config' => '/workspace/build.config.yml', + 'default_channels' => ['stable', 'staging', 'dev', 'nightly'], + ], + 'links' => [ + 'hub' => '/app/androidcast_project/', + 'crashes' => '/app/androidcast_project/crashes/', + 'git' => '/app/androidcast_project/git/', + 'tickets' => '/app/androidcast_project/crashes/?view=tickets', + ], +]; diff --git a/public/api/build_actions.php b/public/api/build_actions.php new file mode 100644 index 0000000..077c834 --- /dev/null +++ b/public/api/build_actions.php @@ -0,0 +1,55 @@ + false, 'error' => 'POST required'], 405); +} + +$raw = file_get_contents('php://input') ?: '{}'; +$body = json_decode($raw, true); +if (!is_array($body)) { + $body = $_POST; +} + +$action = (string) ($body['action'] ?? ''); +$buildId = (int) ($body['build_id'] ?? 0); +if ($buildId <= 0) { + json_out(['ok' => false, 'error' => 'build_id required'], 400); +} + +$user = Auth::user(); +$userId = isset($user['id']) ? (int) $user['id'] : null; + +try { + if ($action === 'stop') { + $ok = BuildRunner::stopBuild($buildId); + json_out(['ok' => $ok, 'build' => BuildRepository::getById($buildId)]); + } + if ($action === 'rerun') { + $build = BuildRunner::rerunBuild($buildId, null, $userId); + json_out(['ok' => true, 'build' => $build]); + } + if ($action === 'force_rerun') { + $build = BuildRunner::forceRerun($buildId, $userId); + json_out(['ok' => true, 'build' => $build]); + } + if ($action === 'rerun_as') { + $override = is_array($body['params'] ?? null) ? $body['params'] : []; + $build = BuildRunner::rerunBuild($buildId, $override, $userId); + json_out(['ok' => true, 'build' => $build]); + } + if ($action === 'rerun_from_failed') { + $build = BuildRunner::rerunFromFailed($buildId, $userId, false); + json_out(['ok' => true, 'build' => $build, 'resume_from' => BuildRunner::detectFailedStep($buildId)]); + } + if ($action === 'rerun_with_ssh') { + $build = BuildRunner::rerunFromFailed($buildId, $userId, true); + json_out(['ok' => true, 'build' => $build, 'resume_from' => BuildRunner::detectFailedStep($buildId)]); + } + json_out(['ok' => false, 'error' => 'unknown_action'], 400); +} catch (Throwable $e) { + error_log('build_actions: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => $e->getMessage()], 500); +} diff --git a/public/api/build_log.php b/public/api/build_log.php new file mode 100644 index 0000000..d4587ac --- /dev/null +++ b/public/api/build_log.php @@ -0,0 +1,39 @@ + false, 'error' => 'id required'], 400); +} +$build = BuildRepository::getById($id); +if (!$build) { + json_out(['ok' => false, 'error' => 'not found'], 404); +} +$logPath = $build['log_path'] ?? null; +if ($download) { + if (!$logPath || !is_file($logPath)) { + json_out(['ok' => false, 'error' => 'log not found'], 404); + } + $code = preg_replace('/[^a-zA-Z0-9._-]+/', '_', (string) ($build['build_code'] ?? 'build')); + header('Content-Type: text/plain; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $code . '.log"'); + readfile($logPath); + exit; +} +$tail = $full + ? BuildRunner::readLogFull($logPath) + : BuildRunner::readLogTail($logPath, $offset); +if ($build['status'] === 'running' && $tail['eof'] && is_file(BuildRunner::artifactDir($id) . '/BUILD_INFO.json')) { + BuildRunner::finalizeFromArtifacts($id); + $build = BuildRepository::getById($id); +} +json_out([ + 'ok' => true, + 'build' => $build, + 'log' => $tail, +]); diff --git a/public/api/build_trigger.php b/public/api/build_trigger.php new file mode 100644 index 0000000..be05f77 --- /dev/null +++ b/public/api/build_trigger.php @@ -0,0 +1,27 @@ + false, 'error' => 'POST required'], 405); +} + +$raw = file_get_contents('php://input') ?: '{}'; +$params = json_decode($raw, true); +if (!is_array($params)) { + $params = $_POST; +} + +$params['pipeline_yaml'] = BuildRunner::resolvePipelineYaml($params); +$params['run_tests'] = !empty($params['run_tests']); +$params['run_native'] = array_key_exists('run_native', $params) ? !empty($params['run_native']) : true; +$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']); +$params['ota_channel'] = $params['ota_channel'] ?? 'staging'; +$params['gradle_task'] = $params['gradle_task'] ?? 'assembleDebug'; + +$user = Auth::user(); +$build = BuildRunner::enqueue($params, isset($user['id']) ? (int) $user['id'] : null); +json_out(['ok' => true, 'build' => $build]); diff --git a/public/api/builds.php b/public/api/builds.php new file mode 100644 index 0000000..49a04cb --- /dev/null +++ b/public/api/builds.php @@ -0,0 +1,7 @@ + true, 'builds' => BuildRepository::listRecent(10)]); diff --git a/public/api/health.php b/public/api/health.php new file mode 100644 index 0000000..e289d91 --- /dev/null +++ b/public/api/health.php @@ -0,0 +1,4 @@ + true, 'health' => BuildRepository::healthSummary()]); diff --git a/public/api/heartbeat.php b/public/api/heartbeat.php new file mode 100644 index 0000000..f88a5d7 --- /dev/null +++ b/public/api/heartbeat.php @@ -0,0 +1,51 @@ + 'method_not_allowed']); + exit; +} + +$raw = file_get_contents('php://input') ?: ''; +$req = json_decode($raw, true); +if (!is_array($req)) { + http_response_code(400); + echo json_encode(['error' => 'invalid_json']); + exit; +} + +$heartbeat = $req['heartbeat'] ?? []; +if (!is_array($heartbeat)) { + $heartbeat = []; +} + +$type = (string)($heartbeat['type'] ?? 'generic'); +$srvEpoch = time(); +$srvTz = date('O'); + +$resp = [ + 'heartbeat' => [ + 'type' => $type, + 'server_date' => $srvEpoch, + 'server_tz' => $srvTz, + ], +]; + +if ($type === 'ntp') { + $clientDate = (int)($heartbeat['date'] ?? 0); + $clientTz = (string)($heartbeat['tz'] ?? ''); + $resp['heartbeat']['client_date'] = $clientDate; + $resp['heartbeat']['client_tz'] = $clientTz; + $resp['heartbeat']['correction_seconds'] = $clientDate > 0 ? ($srvEpoch - $clientDate) : null; + $resp['heartbeat']['enabled'] = true; +} + +if ($type === 'ip') { + $resp['heartbeat']['external_ip'] = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''); +} + +echo json_encode($resp, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/public/assets/js/builder.js b/public/assets/js/builder.js new file mode 100644 index 0000000..2747e30 --- /dev/null +++ b/public/assets/js/builder.js @@ -0,0 +1,355 @@ +(function () { + 'use strict'; + var base = document.body.getAttribute('data-base-path') || ''; + + function buildStatusIconHtml(status) { + var s = String(status || '').toLowerCase(); + if (s === 'running' || s === 'queued') { + return '' + + ''; + } + if (s === 'passed') { + return ''; + } + return '' + + ''; + } + + function updateBuildStatusCell(cell, status, phase) { + if (!cell) return; + var icon = cell.querySelector('.build-status-icon'); + var label = cell.querySelector('.build-status-label'); + var html = buildStatusIconHtml(status); + if (icon) { + icon.outerHTML = html; + } else { + cell.insertAdjacentHTML('afterbegin', html); + } + if (label) { + label.textContent = status || ''; + } + if (phase !== undefined) { + var row = cell.closest('tr'); + if (row) { + var phaseCell = row.querySelector('.build-phase-cell'); + if (phaseCell) { + phaseCell.textContent = phase || ''; + } + } + } + } + + function updateBuildRow(row, build) { + if (!row || !build) return; + row.setAttribute('data-build-status', build.status || ''); + var cell = row.querySelector('[data-build-status-cell="' + build.id + '"]'); + updateBuildStatusCell(cell, build.status, build.phase); + var play = row.querySelector('.build-transport-play'); + var stop = row.querySelector('.build-transport-stop'); + var active = build.status === 'running' || build.status === 'queued'; + if (play) { + play.setAttribute('data-build-status', build.status || ''); + } + if (stop) { + stop.setAttribute('data-build-status', build.status || ''); + stop.disabled = !active; + } + } + + function refreshRecentBuilds() { + var tbody = document.getElementById('builds-recent-tbody'); + if (!tbody) return; + var xhr = new XMLHttpRequest(); + xhr.open('GET', base + '/api/builds.php', true); + xhr.onload = function () { + var j = null; + try { j = JSON.parse(xhr.responseText); } catch (e) { j = null; } + if (!j || !j.ok || !j.builds) return; + j.builds.forEach(function (b) { + var row = tbody.querySelector('tr[data-build-id="' + b.id + '"]'); + updateBuildRow(row, b); + }); + }; + xhr.send(); + setTimeout(refreshRecentBuilds, 1000); + } + + function bindBuildListTransport() { + var tbody = document.getElementById('builds-recent-tbody'); + if (!tbody) return; + tbody.addEventListener('click', function (ev) { + var play = ev.target.closest('.build-transport-play'); + var stop = ev.target.closest('.build-transport-stop'); + if (play) { + var pid = Number(play.getAttribute('data-build-id')); + var pst = play.getAttribute('data-build-status') || ''; + if (!pid) return; + var msg = pst === 'running' || pst === 'queued' + ? 'Force re-run? This stops the current run and starts a new build with the same parameters.' + : null; + if (msg && !confirm(msg)) return; + postBuildAction({ action: 'force_rerun', build_id: pid }).then(function (j) { + if (j.build && j.build.id) { + window.location.href = base + '/?view=build&id=' + j.build.id; + } + }).catch(function (err) { + alert(err.message || 'Re-run failed'); + }); + return; + } + if (stop) { + var sid = Number(stop.getAttribute('data-build-id')); + if (!sid || stop.disabled) return; + if (!confirm('Stop this build now?')) return; + postBuildAction({ action: 'stop', build_id: sid }).then(function () { + refreshRecentBuilds(); + }).catch(function (err) { + alert(err.message || 'Stop failed'); + }); + } + }); + } + + if (document.body.getAttribute('data-view') === 'home' && document.getElementById('builds-recent-tbody')) { + bindBuildListTransport(); + refreshRecentBuilds(); + } + + var themeSel = document.getElementById('theme-select'); + if (themeSel) { + var cur = localStorage.getItem('crash_console_theme') || 'dark'; + themeSel.value = cur; + themeSel.addEventListener('change', function () { + document.documentElement.setAttribute('data-theme', themeSel.value); + localStorage.setItem('crash_console_theme', themeSel.value); + }); + } + + var form = document.getElementById('build-trigger-form'); + if (form) { + form.addEventListener('submit', function (ev) { + ev.preventDefault(); + var status = document.getElementById('build-trigger-status'); + var fd = new FormData(form); + var payload = { + git_ref: fd.get('git_ref') || 'next', + gradle_task: fd.get('gradle_task') || 'assembleDebug', + ota_channel: fd.get('ota_channel') || 'staging', + run_tests: !!form.querySelector('[name=run_tests]').checked, + 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 + }; + status.textContent = 'Starting…'; + fetch(base + '/api/build_trigger.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(payload) + }).then(function (r) { + return r.text().then(function (text) { + var j = null; + try { + j = JSON.parse(text); + } catch (e) { + throw new Error(r.status + ': ' + (text.slice(0, 120) || r.statusText)); + } + if (!r.ok) { + throw new Error((j && j.error) || ('HTTP ' + r.status)); + } + return j; + }); + }).then(function (j) { + if (j.ok && j.build && j.build.id) { + window.location.href = base + '/?view=build&id=' + j.build.id; + } else { + status.textContent = j.error || 'Failed'; + } + }).catch(function (err) { + status.textContent = err && err.message ? err.message : 'Network error'; + }); + }); + } + + function postBuildAction(payload) { + return fetch(base + '/api/build_actions.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(payload) + }).then(function (r) { + return r.text().then(function (text) { + var j = null; + try { j = JSON.parse(text); } catch (e) { + throw new Error(r.status + ': ' + (text.slice(0, 120) || r.statusText)); + } + if (!r.ok || !j.ok) { + throw new Error((j && j.error) || ('HTTP ' + r.status)); + } + return j; + }); + }); + } + + var rerunBtn = document.getElementById('build-rerun-btn'); + var rerunFailedBtn = document.getElementById('build-rerun-failed-btn'); + var rerunSshBtn = document.getElementById('build-rerun-ssh-btn'); + var stopBtn = document.getElementById('build-stop-btn'); + var rerunAsToggle = document.getElementById('build-rerun-as-toggle'); + var rerunAsForm = document.getElementById('build-rerun-as-form'); + var actionStatus = document.getElementById('build-action-status'); + + function goToBuild(j) { + if (j.build && j.build.id) { + window.location.href = base + '/?view=build&id=' + j.build.id; + } + } + + function bindRerunAction(btn, action, confirmMsg) { + if (!btn) return; + btn.addEventListener('click', function () { + var id = Number(btn.getAttribute('data-build-id')); + if (!id) return; + if (confirmMsg && !confirm(confirmMsg)) return; + if (actionStatus) actionStatus.textContent = 'Starting…'; + postBuildAction({ action: action, build_id: id }).then(goToBuild).catch(function (err) { + if (actionStatus) actionStatus.textContent = err.message || 'Failed'; + }); + }); + } + if (rerunAsToggle && rerunAsForm) { + rerunAsToggle.addEventListener('click', function () { + rerunAsForm.hidden = !rerunAsForm.hidden; + }); + } + bindRerunAction(rerunBtn, 'rerun', null); + bindRerunAction(rerunFailedBtn, 'rerun_from_failed', null); + bindRerunAction( + rerunSshBtn, + 'rerun_with_ssh', + 'Start an SSH debug session on the builder? You will need shell access to the builder host.' + ); + if (stopBtn) { + stopBtn.addEventListener('click', function () { + var id = Number(stopBtn.getAttribute('data-build-id')); + if (!id || !confirm('Stop this build?')) return; + if (actionStatus) actionStatus.textContent = 'Stopping…'; + postBuildAction({ action: 'stop', build_id: id }).then(function () { + window.location.reload(); + }).catch(function (err) { + if (actionStatus) actionStatus.textContent = err.message || 'Failed'; + }); + }); + } + if (rerunAsForm) { + rerunAsForm.addEventListener('submit', function (ev) { + ev.preventDefault(); + var id = Number(rerunBtn && rerunBtn.getAttribute('data-build-id')); + if (!id) return; + var fd = new FormData(rerunAsForm); + var params = { + git_ref: fd.get('git_ref') || 'next', + gradle_task: fd.get('gradle_task') || 'assembleDebug', + ota_channel: fd.get('ota_channel') || 'staging', + run_tests: !!rerunAsForm.querySelector('[name=run_tests]').checked, + run_native: !!rerunAsForm.querySelector('[name=run_native]').checked, + run_apk: !!rerunAsForm.querySelector('[name=run_apk]').checked, + auto_ota: !!rerunAsForm.querySelector('[name=auto_ota]').checked, + auto_deploy: !!rerunAsForm.querySelector('[name=auto_deploy]').checked + }; + if (actionStatus) actionStatus.textContent = 'Starting…'; + postBuildAction({ action: 'rerun_as', build_id: id, params: params }).then(function (j) { + if (j.build && j.build.id) { + window.location.href = base + '/?view=build&id=' + j.build.id; + } + }).catch(function (err) { + if (actionStatus) actionStatus.textContent = err.message || 'Failed'; + }); + }); + } + + var buildId = document.body.getAttribute('data-build-id'); + var buildStatus = document.body.getAttribute('data-build-status') || ''; + var logEl = document.getElementById('build-log-tail'); + if (buildId && logEl) { + var offset = 0; + var full = ''; + var showFull = false; + var DISPLAY_TAIL = 50000; + + function renderLog() { + logEl.textContent = showFull ? full : full.slice(-DISPLAY_TAIL); + logEl.scrollTop = logEl.scrollHeight; + } + + function poll() { + var url = base + '/api/build_log.php?id=' + encodeURIComponent(buildId) + '&offset=' + offset; + fetch(url) + .then(function (r) { return r.json(); }) + .then(function (j) { + if (!j.ok) return; + if (j.log && j.log.text) { + full += j.log.text; + offset = j.log.offset || offset; + renderLog(); + } + if (j.build) { + var iconEl = document.getElementById('build-status-icon'); + var textEl = document.getElementById('build-status-text'); + if (iconEl) { + iconEl.innerHTML = buildStatusIconHtml(j.build.status); + } + if (textEl) { + var errEl = document.getElementById('build-error-message'); + var err = j.build.error_message ? (' · ' + + String(j.build.error_message).replace(/&/g, '&').replace(//g, '>') + + '') : ''; + textEl.innerHTML = 'Status: ' + (j.build.status || '') + ' · Phase: ' + + (j.build.phase || '') + err; + } + document.body.setAttribute('data-build-status', j.build.status || ''); + } + var running = j.build && j.build.status === 'running'; + var eof = j.log && j.log.eof; + if (running || !eof) { + setTimeout(poll, running ? 1000 : 250); + } else if (j.build && j.build.phase === 'ssh_debug' && !document.getElementById('build-ssh-panel')) { + window.location.reload(); + } + }).catch(function () { setTimeout(poll, 2000); }); + } + + function loadFullLog() { + return fetch(base + '/api/build_log.php?id=' + encodeURIComponent(buildId) + '&full=1') + .then(function (r) { return r.json(); }) + .then(function (j) { + if (j.ok && j.log && j.log.text) { + full = j.log.text; + offset = j.log.offset || 0; + renderLog(); + } + }); + } + + if (buildStatus && buildStatus !== 'running' && buildStatus !== 'queued') { + loadFullLog().catch(function () { poll(); }); + } else { + poll(); + } + + var expand = document.getElementById('build-log-expand'); + if (expand) { + expand.addEventListener('click', function () { + showFull = true; + if (full === '') { + loadFullLog().then(function () { renderLog(); }); + } else { + renderLog(); + } + expand.textContent = 'Showing full log'; + expand.disabled = true; + }); + } + } +})(); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..03698bb --- /dev/null +++ b/public/index.php @@ -0,0 +1,88 @@ +exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS builds ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + build_code VARCHAR(64) NOT NULL, + status ENUM('queued','running','passed','failed','cancelled') NOT NULL DEFAULT 'queued', + phase VARCHAR(64) NOT NULL DEFAULT 'init', + git_ref VARCHAR(256) NULL, + git_sha VARCHAR(64) NULL, + branch VARCHAR(128) NULL, + pipeline_yaml LONGTEXT NULL, + params_json LONGTEXT NOT NULL, + dockerfile_version VARCHAR(32) NOT NULL DEFAULT '00.01.00.1000', + builder_id VARCHAR(128) NOT NULL DEFAULT 'local-docker', + version_app VARCHAR(32) NULL, + version_code INT NULL, + target_os VARCHAR(32) NOT NULL DEFAULT 'android', + min_api_level INT NOT NULL DEFAULT 24, + ota_channel VARCHAR(64) NULL, + auto_ota TINYINT(1) NOT NULL DEFAULT 0, + auto_deploy TINYINT(1) NOT NULL DEFAULT 0, + log_path VARCHAR(512) NULL, + artifacts_json LONGTEXT NULL, + error_message VARCHAR(512) NULL, + created_by_user_id INT UNSIGNED NULL, + started_at TIMESTAMP NULL, + finished_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_builds_code (build_code), + KEY idx_builds_status (status), + KEY idx_builds_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL); + } + + public static function create(array $row): int { + self::ensureSchema(); + $pdo = Database::pdo(); + $stmt = $pdo->prepare(<<<'SQL' +INSERT INTO builds ( + build_code, status, phase, git_ref, git_sha, branch, pipeline_yaml, params_json, + dockerfile_version, builder_id, target_os, min_api_level, ota_channel, auto_ota, auto_deploy, + log_path, created_by_user_id +) VALUES ( + :build_code, :status, :phase, :git_ref, :git_sha, :branch, :pipeline_yaml, :params_json, + :dockerfile_version, :builder_id, :target_os, :min_api_level, :ota_channel, :auto_ota, :auto_deploy, + :log_path, :created_by_user_id +) +SQL); + $stmt->execute([ + ':build_code' => $row['build_code'], + ':status' => $row['status'] ?? 'queued', + ':phase' => $row['phase'] ?? 'queued', + ':git_ref' => $row['git_ref'] ?? null, + ':git_sha' => $row['git_sha'] ?? null, + ':branch' => $row['branch'] ?? null, + ':pipeline_yaml' => $row['pipeline_yaml'] ?? null, + ':params_json' => $row['params_json'] ?? '{}', + ':dockerfile_version' => $row['dockerfile_version'] ?? '00.01.00.1000', + ':builder_id' => $row['builder_id'] ?? 'local-docker', + ':target_os' => $row['target_os'] ?? 'android', + ':min_api_level' => (int) ($row['min_api_level'] ?? 24), + ':ota_channel' => $row['ota_channel'] ?? null, + ':auto_ota' => !empty($row['auto_ota']) ? 1 : 0, + ':auto_deploy' => !empty($row['auto_deploy']) ? 1 : 0, + ':log_path' => $row['log_path'] ?? null, + ':created_by_user_id' => $row['created_by_user_id'] ?? null, + ]); + return (int) $pdo->lastInsertId(); + } + + public static function update(int $id, array $fields): void { + self::ensureSchema(); + $allowed = ['status', 'phase', 'version_app', 'version_code', 'artifacts_json', 'error_message', 'started_at', 'finished_at', 'git_sha', 'log_path']; + $sets = []; + $params = [':id' => $id]; + foreach ($fields as $k => $v) { + if (!in_array($k, $allowed, true)) { + continue; + } + $sets[] = "$k = :$k"; + $params[":$k"] = $v; + } + if (!$sets) { + return; + } + $sql = 'UPDATE builds SET ' . implode(', ', $sets) . ' WHERE id = :id'; + Database::pdo()->prepare($sql)->execute($params); + } + + public static function getById(int $id): ?array { + self::ensureSchema(); + $stmt = Database::pdo()->prepare('SELECT * FROM builds WHERE id = ? LIMIT 1'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + return $row ?: null; + } + + public static function listRecent(int $limit = 10): array { + self::ensureSchema(); + $stmt = Database::pdo()->prepare('SELECT * FROM builds ORDER BY id DESC LIMIT ?'); + $stmt->bindValue(1, $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll() ?: []; + } + + public static function healthSummary(): array { + self::ensureSchema(); + BuildRunner::reconcileRunningBuilds(); + BuildRunner::reconcileQueuedBuilds(); + $pdo = Database::pdo(); + $running = (int) $pdo->query("SELECT COUNT(*) FROM builds WHERE status = 'running'")->fetchColumn(); + $failed = (int) $pdo->query("SELECT COUNT(*) FROM builds WHERE status = 'failed' AND created_at > (NOW() - INTERVAL 7 DAY)")->fetchColumn(); + $passed = (int) $pdo->query("SELECT COUNT(*) FROM builds WHERE status = 'passed' AND created_at > (NOW() - INTERVAL 7 DAY)")->fetchColumn(); + $dockerOk = BuildRunner::dockerAvailable(); + return [ + 'docker' => $dockerOk ? 'ok' : 'down', + 'running' => $running, + 'passed_7d' => $passed, + 'failed_7d' => $failed, + 'ci_version' => cfg('build.ci_version', '00.01.00.1000'), + ]; + } +} diff --git a/src/BuildRunner.php b/src/BuildRunner.php new file mode 100644 index 0000000..e9ed74c --- /dev/null +++ b/src/BuildRunner.php @@ -0,0 +1,543 @@ +/dev/null')); + return $which !== '' ? $which : 'docker'; + } + + public static function dockerAvailable(): bool { + $bin = escapeshellarg(self::dockerBinary()); + return trim((string) shell_exec("{$bin} info >/dev/null 2>&1 && echo ok")) === 'ok'; + } + + public static function dockerCheckDetail(): string { + $bin = self::dockerBinary(); + $sock = '/var/run/docker.sock'; + $parts = ['bin=' . $bin]; + if (!is_executable($bin)) { + $parts[] = 'bin_not_executable'; + } + if (!is_readable($sock)) { + $parts[] = 'sock_not_readable'; + } + $parts[] = self::dockerAvailable() ? 'info_ok' : 'info_failed'; + return implode(', ', $parts); + } + + public static function generateBuildCode(): string { + return 'bld-' . gmdate('Ymd-His') . '-' . substr(bin2hex(random_bytes(3)), 0, 6); + } + + public static function resolvePipelineYaml(array $params): string { + $candidates = array_filter([ + (string) cfg('build.pipeline_config', ''), + rtrim((string) cfg('build.repo_root', ''), '/') . '/build.config.yml', + ]); + foreach ($candidates as $path) { + if ($path !== '' && is_file($path)) { + $text = (string) file_get_contents($path); + if ($text !== '') { + return $text; + } + } + } + return self::generatePipelineYaml($params); + } + + public static function generatePipelineYaml(array $params): string { + $gitRef = (string) ($params['git_ref'] ?? 'next'); + $gradle = (string) ($params['gradle_task'] ?? 'assembleDebug'); + $channel = (string) ($params['ota_channel'] ?? 'staging'); + $runTests = !empty($params['run_tests']) ? 'true' : 'false'; + $runNative = !empty($params['run_native']) ? 'true' : 'false'; + $runApk = !empty($params['run_apk']) ? 'true' : 'false'; + $autoOta = !empty($params['auto_ota']) ? 'true' : 'false'; + $ciVer = (string) cfg('build.ci_version', '00.01.00.1000'); + return << $buildCode, + 'status' => 'queued', + 'phase' => 'queued', + 'git_ref' => $params['git_ref'] ?? null, + 'git_sha' => $params['git_sha'] ?? null, + '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'), + '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, + ]); + $dir = self::artifactDir($id); + if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) { + throw new RuntimeException('Cannot create artifact dir: ' . $dir); + } + $logPath = $dir . '/build.log'; + BuildRepository::update($id, ['log_path' => $logPath]); + + if (!self::dockerAvailable()) { + $detail = self::dockerCheckDetail(); + self::failBuild( + $id, + 'Docker daemon not available (' . $detail . '). ' + . 'Ensure docker service is running and PHP-FPM user (nginx) is in group docker, then restart php-fpm.', + $logPath + ); + return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode, 'status' => 'failed']; + } + + $phase = !empty($params['ssh_debug']) ? 'ssh_debug' : 'docker'; + $runner = (string) cfg('build.runner_script', '/workspace/scripts/docker-build-runner.sh'); + $repo = (string) cfg('build.repo_root', '/workspace'); + $gitRemote = trim((string) shell_exec( + 'git -c safe.directory=' . escapeshellarg($repo) + . ' -C ' . escapeshellarg($repo) + . ' remote get-url origin 2>/dev/null' + )); + $env = [ + 'BUILD_ID=' . $id, + 'BUILD_LOG_FILE=' . escapeshellarg($logPath), + 'BUILD_LOG_STDOUT_ONLY=1', + 'BUILD_ISOLATE_SOURCE=' . (cfg('build.isolate_source', true) ? '1' : '0'), + 'BUILD_GIT_DEPTH=' . max(1, (int) cfg('build.git_clone_depth', 1)), + 'BUILD_WORK_DIR=' . escapeshellarg($repo), + 'BUILD_OUT_DIR=' . escapeshellarg($dir), + 'ANDROIDCAST_CI_VERSION=' . escapeshellarg((string) cfg('build.ci_version', '00.01.00.1000')), + 'GIT_REF=' . escapeshellarg((string) ($params['git_ref'] ?? '')), + 'GIT_SHA=' . escapeshellarg((string) ($params['git_sha'] ?? '')), + 'GIT_REMOTE=' . escapeshellarg($gitRemote), + 'GRADLE_TASK=' . escapeshellarg((string) ($params['gradle_task'] ?? 'assembleDebug')), + 'RUN_UNIT_TESTS=' . (!empty($params['run_tests']) ? '1' : '0'), + 'RUN_NATIVE=' . (!empty($params['run_native']) ? '1' : '0'), + 'RUN_APK=' . (!empty($params['run_apk']) ? '1' : '0'), + 'OTA_BASE_URL=' . escapeshellarg((string) ($params['ota_base_url'] ?? cfg('build.ota_base_url', ''))), + 'OTA_CHANNEL=' . escapeshellarg((string) ($params['ota_channel'] ?? 'staging')), + 'AUTO_OTA=' . (!empty($params['auto_ota']) ? '1' : '0'), + ]; + if (!empty($params['resume_from'])) { + $env[] = 'BUILD_RESUME_FROM=' . escapeshellarg((string) $params['resume_from']); + } + if (!empty($params['reuse_build_id'])) { + $reuseSrc = self::artifactDir((int) $params['reuse_build_id']) . '/src'; + $env[] = 'BUILD_REUSE_SRC_DIR=' . escapeshellarg($reuseSrc); + } + if (!empty($params['ssh_debug'])) { + $env[] = 'BUILD_SSH_DEBUG=1'; + } + $cmd = implode(' ', $env) . ' nohup ' . escapeshellarg($runner) . ' >> ' . escapeshellarg($logPath) . ' 2>&1 & echo $!'; + $pid = trim((string) shell_exec($cmd)); + if ($pid === '' || !ctype_digit($pid)) { + self::failBuild($id, 'Build runner failed to start', $logPath); + return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode, 'status' => 'failed']; + } + file_put_contents($dir . '/runner.pid', $pid); + BuildRepository::update($id, [ + 'status' => 'running', + 'phase' => $phase, + 'started_at' => gmdate('Y-m-d H:i:s'), + ]); + + return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode]; + } + + public static function failBuild(int $id, string $message, ?string $logPath = null): void { + if ($logPath) { + file_put_contents($logPath, "\n[builder] ERROR: {$message}\n", FILE_APPEND); + } + BuildRepository::update($id, [ + 'status' => 'failed', + 'phase' => 'error', + 'error_message' => substr($message, 0, 512), + 'finished_at' => gmdate('Y-m-d H:i:s'), + ]); + } + + public static function stopBuild(int $id): bool { + $build = BuildRepository::getById($id); + if (!$build) { + return false; + } + $status = (string) ($build['status'] ?? ''); + if ($status === 'queued') { + $dir = self::artifactDir($id); + $logPath = $build['log_path'] ?? ($dir . '/build.log'); + if ($logPath) { + file_put_contents($logPath, "\n[builder] Stopped while queued\n", FILE_APPEND); + } + BuildRepository::update($id, [ + 'status' => 'cancelled', + 'phase' => 'stopped', + 'error_message' => 'Stopped while queued', + 'finished_at' => gmdate('Y-m-d H:i:s'), + ]); + return true; + } + if ($status !== 'running') { + return false; + } + $dir = self::artifactDir($id); + $pidFile = $dir . '/runner.pid'; + $pid = is_file($pidFile) ? trim((string) file_get_contents($pidFile)) : ''; + if ($pid !== '' && ctype_digit($pid)) { + shell_exec('kill ' . escapeshellarg($pid) . ' 2>/dev/null'); + } + self::stopDebugContainer($id); + $logPath = $build['log_path'] ?? ($dir . '/build.log'); + if ($logPath) { + file_put_contents($logPath, "\n[builder] Stopped by operator\n", FILE_APPEND); + } + BuildRepository::update($id, [ + 'status' => 'cancelled', + 'phase' => 'stopped', + 'error_message' => 'Stopped by operator', + 'finished_at' => gmdate('Y-m-d H:i:s'), + ]); + return true; + } + + /** @return array */ + public static function rerunBuild(int $id, ?array $overrideParams = null, ?int $userId = null): array { + $build = BuildRepository::getById($id); + if (!$build) { + throw new InvalidArgumentException('build_not_found'); + } + $params = json_decode((string) ($build['params_json'] ?? '{}'), true); + if (!is_array($params)) { + $params = []; + } + if ($overrideParams) { + $params = array_merge($params, $overrideParams); + } + if (empty($params['git_ref'])) { + $params['git_ref'] = $build['git_ref'] ?? $build['branch'] ?? 'next'; + } + if (!isset($params['rerun_of'])) { + $params['rerun_of'] = $id; + } + return self::enqueue($params, $userId); + } + + /** Stop an in-flight build (if any) and enqueue a fresh run with the same params. */ + public static function forceRerun(int $id, ?int $userId = null): array { + $build = BuildRepository::getById($id); + if (!$build) { + throw new InvalidArgumentException('build_not_found'); + } + $status = (string) ($build['status'] ?? ''); + if (in_array($status, ['running', 'queued'], true)) { + self::stopBuild($id); + } + return self::rerunBuild($id, null, $userId); + } + + /** CircleCI-style: resume from the step that failed (git → docker-build → docker-run). */ + public static function detectFailedStep(int $buildId): string { + $dir = self::artifactDir($buildId); + $logTail = ''; + $logPath = $dir . '/build.log'; + if (is_file($logPath)) { + $size = filesize($logPath) ?: 0; + $logTail = (string) file_get_contents($logPath, false, null, max(0, $size - 65536)); + } + $hasRun = is_file($dir . '/docker-run.log') && (filesize($dir . '/docker-run.log') ?: 0) > 0; + $hasBuild = is_file($dir . '/docker-build.log') && (filesize($dir . '/docker-build.log') ?: 0) > 0; + + if ($hasRun && !is_file($dir . '/android_cast-latest.apk')) { + return 'docker-run'; + } + if ($hasBuild || str_contains($logTail, '[runner] step docker-build')) { + if (!str_contains($logTail, '[runner] step docker-run') && !str_contains($logTail, '[runner] finished')) { + return 'docker-build'; + } + $buildStep = $hasBuild ? (string) file_get_contents($dir . '/docker-build.log') : ''; + if (str_contains($logTail, 'ERROR: failed to build') || str_contains($buildStep, 'ERROR: failed to build')) { + return 'docker-build'; + } + } + if (str_contains($logTail, 'isolate:') || str_contains($logTail, 'syncing shared checkout') + || str_contains($logTail, 'dubious ownership') || is_file($dir . '/git-clone.log')) { + return 'git'; + } + return $hasRun ? 'docker-run' : ($hasBuild ? 'docker-build' : 'git'); + } + + /** @return array */ + public static function rerunFromFailed(int $id, ?int $userId = null, bool $sshDebug = false): array { + $build = BuildRepository::getById($id); + if (!$build) { + throw new InvalidArgumentException('build_not_found'); + } + $status = (string) ($build['status'] ?? ''); + if (!in_array($status, ['failed', 'cancelled'], true)) { + throw new InvalidArgumentException('rerun_from_failed_requires_failed_build'); + } + $step = self::detectFailedStep($id); + return self::rerunBuild($id, [ + 'resume_from' => $step, + 'reuse_build_id' => $id, + 'ssh_debug' => $sshDebug, + 'rerun_of' => $id, + ], $userId); + } + + public static function debugContainerName(int $buildId): string { + return 'androidcast-bld-' . $buildId; + } + + public static function debugContainerAlive(int $buildId): bool { + $bin = escapeshellarg(self::dockerBinary()); + $name = escapeshellarg(self::debugContainerName($buildId)); + return trim((string) shell_exec("{$bin} inspect -f '{{.State.Running}}' {$name} 2>/dev/null")) === 'true'; + } + + public static function stopDebugContainer(int $buildId): void { + $bin = escapeshellarg(self::dockerBinary()); + $name = escapeshellarg(self::debugContainerName($buildId)); + shell_exec("{$bin} rm -f {$name} 2>/dev/null"); + } + + public static function runnerAlive(int $buildId): bool { + $pidFile = self::artifactDir($buildId) . '/runner.pid'; + $pid = is_file($pidFile) ? trim((string) file_get_contents($pidFile)) : ''; + if ($pid === '' || !ctype_digit($pid)) { + return false; + } + $out = trim((string) shell_exec('kill -0 ' . escapeshellarg($pid) . ' 2>/dev/null && echo alive')); + return $out === 'alive'; + } + + public static function reconcileRunningBuilds(): void { + BuildRepository::ensureSchema(); + $stmt = Database::pdo()->query("SELECT id, log_path FROM builds WHERE status = 'running'"); + if (!$stmt) { + return; + } + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $id = (int) ($row['id'] ?? 0); + if ($id <= 0) { + continue; + } + $dir = self::artifactDir($id); + $logPath = (string) ($row['log_path'] ?? ($dir . '/build.log')); + if (is_file($dir . '/android_cast-latest.apk')) { + self::finalizeFromArtifacts($id); + continue; + } + $tail = ''; + if (is_file($logPath)) { + $size = filesize($logPath) ?: 0; + $tail = (string) file_get_contents($logPath, false, null, max(0, $size - 8192)); + } + $modeFile = $dir . '/runner.mode'; + if (is_file($modeFile) && trim((string) file_get_contents($modeFile)) === 'ssh_debug') { + if (self::debugContainerAlive($id)) { + BuildRepository::update($id, ['status' => 'running', 'phase' => 'ssh_debug']); + continue; + } + self::failBuild($id, 'SSH debug container ended', $logPath); + continue; + } + if (self::runnerAlive($id)) { + continue; + } + 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); + } elseif ($tail !== '') { + $hint = self::summarizeLogFailure($logPath); + $msg = $hint !== '' ? $hint : 'Build runner exited unexpectedly'; + self::failBuild($id, $msg, $logPath); + } else { + self::failBuild($id, 'Build runner never started', $logPath); + } + } + } + + public static function reconcileQueuedBuilds(): void { + BuildRepository::ensureSchema(); + $stmt = Database::pdo()->query("SELECT id, log_path, created_at FROM builds WHERE status = 'queued'"); + if (!$stmt) { + return; + } + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $id = (int) ($row['id'] ?? 0); + if ($id <= 0) { + continue; + } + $logPath = (string) ($row['log_path'] ?? (self::artifactDir($id) . '/build.log')); + if (self::runnerAlive($id)) { + BuildRepository::update($id, [ + 'status' => 'running', + 'phase' => 'docker', + 'started_at' => gmdate('Y-m-d H:i:s'), + ]); + continue; + } + $created = strtotime((string) ($row['created_at'] ?? '')) ?: 0; + if ($created > 0 && $created < time() - 120) { + self::failBuild($id, 'Build stuck in queued (runner never started)', $logPath); + } + } + } + + /** Last lines / markers from build.log for reconcile error_message (max 512 chars). */ + public static function summarizeLogFailure(?string $logPath): string { + if (!$logPath || !is_file($logPath)) { + return ''; + } + $size = filesize($logPath) ?: 0; + $chunk = (string) file_get_contents($logPath, false, null, max(0, $size - 32768)); + if ($chunk === '') { + return ''; + } + $lines = preg_split('/\r\n|\r|\n/', $chunk) ?: []; + $markers = []; + foreach ($lines as $line) { + $trim = trim($line); + if ($trim === '') { + continue; + } + if (preg_match('/\[runner\] ERROR:|\[builder\] ERROR:|fatal:|BUILD FAILED|Cannot connect to the Docker daemon/i', $trim)) { + $markers[] = $trim; + } + } + $tailLines = array_values(array_filter($lines, static fn(string $l): bool => trim($l) !== '')); + $last = array_slice($tailLines, -4); + $parts = $markers !== [] ? array_slice($markers, -2) : $last; + $summary = implode(' | ', $parts); + if ($summary === '') { + return ''; + } + if (strlen($summary) > 480) { + $summary = '…' . substr($summary, -477); + } + return 'Build runner exited unexpectedly: ' . $summary; + } + + public static function readLogTail(?string $logPath, int $offset = 0, int $maxBytes = 65536): array { + if (!$logPath || !is_file($logPath)) { + return ['offset' => 0, 'eof' => true, 'text' => '', 'size' => 0]; + } + $size = filesize($logPath) ?: 0; + $offset = max(0, min($offset, $size)); + $fh = fopen($logPath, 'rb'); + if (!$fh) { + return ['offset' => $offset, 'eof' => true, 'text' => '', 'size' => $size]; + } + fseek($fh, $offset); + $chunk = fread($fh, $maxBytes) ?: ''; + $newOffset = $offset + strlen($chunk); + fclose($fh); + return ['offset' => $newOffset, 'eof' => $newOffset >= $size, 'text' => $chunk, 'size' => $size]; + } + + public static function readLogFull(?string $logPath, int $maxBytes = 2097152): array { + if (!$logPath || !is_file($logPath)) { + return ['offset' => 0, 'eof' => true, 'text' => '', 'size' => 0, 'truncated' => false]; + } + $size = filesize($logPath) ?: 0; + $truncated = $size > $maxBytes; + $start = $truncated ? $size - $maxBytes : 0; + $text = (string) file_get_contents($logPath, false, null, $start); + if ($truncated) { + $text = "[log truncated: showing last {$maxBytes} of {$size} bytes]\n" . $text; + } + return ['offset' => $size, 'eof' => true, 'text' => $text, 'size' => $size, 'truncated' => $truncated]; + } + + public static function finalizeFromArtifacts(int $buildId): void { + $dir = self::artifactDir($buildId); + $infoPath = $dir . '/BUILD_INFO.json'; + $status = is_file($dir . '/android_cast-latest.apk') ? 'passed' : 'failed'; + $artifacts = []; + if (is_file($dir . '/android_cast-latest.apk')) { + $artifacts['apk'] = 'android_cast-latest.apk'; + } + if (is_dir($dir . '/ota/v0')) { + $artifacts['ota'] = 'ota/v0'; + } + $versionApp = null; + if (is_file($infoPath)) { + $info = json_decode((string) file_get_contents($infoPath), true); + if (is_array($info)) { + $versionApp = $info['gitSha'] ?? null; + } + } + BuildRepository::update($buildId, [ + 'status' => $status, + 'phase' => 'done', + 'artifacts_json' => json_encode($artifacts, JSON_UNESCAPED_SLASHES), + 'version_app' => $versionApp, + 'finished_at' => gmdate('Y-m-d H:i:s'), + ]); + if (!empty($artifacts['ota']) && cfg('build.ota_mount')) { + $build = BuildRepository::getById($buildId); + if (!empty($build['auto_deploy'])) { + self::publishOta($buildId, $dir); + } + } + } + + private static function publishOta(int $buildId, string $dir): void { + $mount = rtrim((string) cfg('build.ota_mount', ''), '/'); + if ($mount === '' || !is_dir($mount)) { + return; + } + $src = $dir . '/ota/v0'; + if (!is_dir($src)) { + return; + } + $build = BuildRepository::getById($buildId); + $channel = $build['ota_channel'] ?? 'staging'; + $dest = $mount . '/v0'; + shell_exec('mkdir -p ' . escapeshellarg($dest) . ' && cp -a ' . escapeshellarg($src . '/.') . ' ' . escapeshellarg($dest . '/')); + if ($channel !== '' && $channel !== 'staging') { + shell_exec('cp -a ' . escapeshellarg($src . '/ota/channel/' . $channel . '.json') . ' ' . escapeshellarg($dest . '/ota/channel/' . $channel . '.json 2>/dev/null')); + } + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php new file mode 100644 index 0000000..cc202d5 --- /dev/null +++ b/src/bootstrap.php @@ -0,0 +1,99 @@ + 'In progress', + 'passed' => 'Succeeded', + 'failed' => 'Failed or cancelled', + ]; + $label = h($labels[$kind] ?? 'Unknown'); + if ($kind === 'running') { + return '' + . ''; + } + if ($kind === 'passed') { + return ''; + } + return '' + . ''; +} diff --git a/views/build_detail.php b/views/build_detail.php new file mode 100644 index 0000000..bb40e4a --- /dev/null +++ b/views/build_detail.php @@ -0,0 +1,124 @@ + 'Git checkout / shallow clone', + 'docker-build' => 'Docker image build', + 'docker-run' => 'Gradle pipeline (docker run)', +]; +?> +

Build

+

+ + Status: · Phase: + + · + + +

+ +

Ecosystem health reports Docker: ok now. The log below is from this failed run — use Re-run (do not only refresh).

+ + +

Failed at step: Re-run from failed skips earlier steps when artifacts allow (like CircleCI).

+ + +
+

Actions

+
+ ← Back + + + + + + + + + + +
+ +
+ SSH debug session +

Shell into the CI container on the builder host (same idea as CircleCI “Rerun with SSH”). Stop ends the session.

+
ssh <user>@
+
+docker exec -it  bash
+
+ + +
+ +
+
+
Builder
+
CI image
+
Target OS
(min API )
+
Git ref
+
Git SHA
+
OTA channel
+
Gradle task
+
+
+ + +
+

Artifacts

+
    + $v): ?> +
  • :
  • + +
+
+ + +
+

Pipeline (YAML)

+

Snapshot stored with this build. New triggers use build.config.yml from the repo when present, otherwise a generated default.

+
+
+ +
+

Build log

+

Live tail shows the last ~50 KB while running. Failed builds load the full log automatically. Per-step files (when present): docker-build.log, docker-run.log under this build’s artifact dir.

+

+  
+ + Download log +
+
diff --git a/views/home.php b/views/home.php new file mode 100644 index 0000000..f6a6970 --- /dev/null +++ b/views/home.php @@ -0,0 +1,83 @@ + +

Android Cast Builder

+

Docker CI for APK baking, OTA staging, and pipeline history.

+ +
+

Ecosystem health

+
    +
  • Docker:
  • +
  • CI image version:
  • +
  • Running builds:
  • +
  • Passed (7d): · Failed (7d):
  • +
+
+ +
+

Run pipeline

+
+
+ + + +
+
+ + + + + +
+ + +
+
+ +
+

Recent builds (last 10)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
CodeStatusPhaseBranch/refChannelCreated
+
+ + +
+
+ + + + +
+
+
diff --git a/views/layout.php b/views/layout.php new file mode 100644 index 0000000..5711866 --- /dev/null +++ b/views/layout.php @@ -0,0 +1,59 @@ + + + + + + + <?= h($pageTitle ?? 'Builder') ?> — <?= h(cfg('app_name')) ?> + + + + + + + > +
+ +
+ + + + + +
+
+ + + diff --git a/views/login.php b/views/login.php new file mode 100644 index 0000000..1e5ba58 --- /dev/null +++ b/views/login.php @@ -0,0 +1,32 @@ + + + + + + + Login — <?= h(cfg('app_name')) ?> + + + + + + + +