diff --git a/Dockerfile b/Dockerfile index 9988d0d..0db1936 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,25 @@ # Minimal Android Cast CI image (glibc-based; NDK prebuilts require linux-x86_64 glibc). # # Build: -# docker build -t androidcast-ci:latest -f Dockerfile . +# docker build --build-arg ANDROIDCAST_CI_VERSION=00.01.00.1000 -t androidcast-ci:00.01.00.1000 -f Dockerfile . # # Run CI build (mount repo): # docker run --rm -v "$(pwd)":/workspace -w /workspace androidcast-ci:latest # -# Interactive: -# docker run --rm -it -v "$(pwd)":/workspace -w /workspace androidcast-ci:latest bash -# -# Note: Alpine/musl breaks NDK clang prebuilts; use Debian slim instead of rust:* for size. +# CI image version (AA.BB.CC.DDDD — AA always 00) +ARG ANDROIDCAST_CI_MAJOR=00 +ARG ANDROIDCAST_CI_MINOR=01 +ARG ANDROIDCAST_CI_PATCH=00 +ARG ANDROIDCAST_CI_BUILD=1000 +ARG ANDROIDCAST_CI_VERSION=00.01.00.1000 FROM debian:bookworm-slim +ARG ANDROIDCAST_CI_VERSION +LABEL org.androidcast.ci.version="${ANDROIDCAST_CI_VERSION}" +LABEL org.opencontainers.image.title="androidcast-ci" +LABEL org.opencontainers.image.version="${ANDROIDCAST_CI_VERSION}" + ARG DEBIAN_FRONTEND=noninteractive ARG ANDROID_SDK_ROOT=/opt/android-sdk ARG ANDROID_NDK_VERSION=27.0.12077973 @@ -26,6 +33,7 @@ ENV ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT}" \ PATH="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${PATH}" \ ANDROIDCAST_CCACHE=1 \ ANDROIDCAST_USE_DISTCC=0 \ + ANDROIDCAST_CI_VERSION="${ANDROIDCAST_CI_VERSION}" \ GRADLE_USER_HOME=/root/.gradle \ JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 @@ -34,7 +42,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ninja-build cmake ccache \ && rm -rf /var/lib/apt/lists/* -# Optional distributed compile (off unless ANDROIDCAST_USE_DISTCC=1 + DISTCC_HOSTS at run time). RUN apt-get update && apt-get install -y --no-install-recommends distcc \ && rm -rf /var/lib/apt/lists/* || true @@ -55,13 +62,13 @@ RUN yes | sdkmanager --licenses >/dev/null \ WORKDIR /workspace -# Pre-create ccache dir (volume can override at runtime). RUN mkdir -p /workspace/build/.ccache || true -COPY scripts/ci-build.sh scripts/android-ndk.sh scripts/native-build-cache.sh scripts/build-native-codecs.sh \ +COPY scripts/ci-build.sh scripts/ci-build-and-publish-ota.sh scripts/generate-ota-v0.sh \ + scripts/android-ndk.sh scripts/native-build-cache.sh scripts/build-native-codecs.sh \ /workspace/scripts/ -RUN chmod +x /workspace/scripts/ci-build.sh /workspace/scripts/build-native-codecs.sh +RUN chmod +x /workspace/scripts/ci-build.sh /workspace/scripts/ci-build-and-publish-ota.sh \ + /workspace/scripts/generate-ota-v0.sh /workspace/scripts/build-native-codecs.sh -# Full tree is bind-mounted in CI; default command runs the pipeline. CMD ["./scripts/ci-build.sh"] diff --git a/docs/BUILD_DEPLOY.md b/docs/BUILD_DEPLOY.md new file mode 100644 index 0000000..c717b08 --- /dev/null +++ b/docs/BUILD_DEPLOY.md @@ -0,0 +1,62 @@ +# Android Cast build ecosystem + +## Shared authentication + +All web consoles (Crashes, Tickets, Builder) share: + +| Setting | Value | +|---------|--------| +| Session name | `ac_crash_sess` | +| Cookie path | `/app/androidcast_project` | +| User store | MariaDB `users` table (same DB as crashes) | +| PHP auth | `examples/crash_reporter/backend/src/Auth.php` | + +Sign in once on any console; the session cookie is valid for `/crashes/`, `/build/`, and future apps under the same path prefix. + +Orchestration generates matching configs: + +- `orchestration/runtime/crash-config.php` +- `orchestration/runtime/build-config.php` + +Both use `${MARIADB_USER}` / `${MARIADB_PASSWORD}` from `orchestration/.env`. + +## Local stack + +```bash +cd orchestration +./deploy.sh +``` + +| URL | Purpose | +|-----|---------| +| `http://localhost:8080/app/androidcast_project/` | Hub landing | +| `…/crashes/` | Crash / ticket console | +| `…/build/` | APK builder console | +| `…/v0/ota/` | Published OTA artifacts (when auto-deploy enabled) | + +Direct debug ports: crashes `:8082`, builder `:8083`. + +## Trigger a build + +1. Open Builder → sign in (default dev user from crash schema seed). +2. Set git ref, pipeline options, OTA channel. +3. **Start build** → detail page polls log every 1s. +4. Artifacts land in `out/builds//`; optional OTA copy to `orchestration/runtime/ota-artifacts/`. + +## Docker CI + +- Image version label: `00.BB.CC.DDDD` (see root `Dockerfile`). +- Host wrapper: `scripts/docker-build-runner.sh` +- In-container pipeline: `scripts/ci-build-and-publish-ota.sh` +- OTA generator: `scripts/generate-ota-v0.sh [channel]` + +## CircleCI + +- Pipeline description: `build.config.yml` +- Local CLI stub: `.circleci/config.yml` (`circleci config validate`) + +## Production notes + +- Mount builder PHP with Docker socket (build host only). +- FE nginx: read-only OTA under `/v0/ota/`; BE RWX on artifact mount. +- Keep `session_cookie_path` identical across all PHP apps behind the hub. diff --git a/examples/app_hub/index.html b/examples/app_hub/index.html index d0cad9e..d38455a 100644 --- a/examples/app_hub/index.html +++ b/examples/app_hub/index.html @@ -11,7 +11,7 @@ })(); - + @@ -122,10 +122,9 @@ var dock = document.getElementById('hub-nav-dock'); if (!dock) return; var globe = globeCoverRect(); - var insetX = Math.max(16, globe.width * 0.06); var dockW = Math.min(300, Math.max(210, globe.width * 0.38)); dock.style.width = dockW + 'px'; - dock.style.left = (globe.left + insetX) + 'px'; + dock.style.left = globe.left + 'px'; dock.style.top = globe.top + 'px'; dock.style.height = globe.height + 'px'; dock.style.display = 'flex'; @@ -159,7 +158,7 @@ var supportsLayout = !!(window.CSS && CSS.supports && CSS.supports('object-fit', 'cover')); if (!supportsSvg || !supportsLayout) return; - var logoBuild = '20260530h'; + var logoBuild = '20260530i'; var fragments = { space: 'assets/hub-logo-fragment-space.svg', globe: 'assets/hub-logo-fragment-globe.svg', @@ -230,10 +229,9 @@ var dock = document.getElementById('hub-nav-dock'); if (!dock) return; var globe = globeCoverRect(); - var insetX = Math.max(16, globe.width * 0.06); var dockW = Math.min(300, Math.max(210, globe.width * 0.38)); dock.style.width = dockW + 'px'; - dock.style.left = (globe.left + insetX) + 'px'; + dock.style.left = globe.left + 'px'; dock.style.top = globe.top + 'px'; dock.style.height = globe.height + 'px'; dock.style.right = 'auto'; diff --git a/examples/build_console/backend/config/config.example.php b/examples/build_console/backend/config/config.example.php new file mode 100644 index 0000000..ec6e0e6 --- /dev/null +++ b/examples/build_console/backend/config/config.example.php @@ -0,0 +1,42 @@ + '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', + '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', + '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/examples/build_console/backend/public/api/build_log.php b/examples/build_console/backend/public/api/build_log.php new file mode 100644 index 0000000..a6ff7c3 --- /dev/null +++ b/examples/build_console/backend/public/api/build_log.php @@ -0,0 +1,24 @@ + false, 'error' => 'id required'], 400); +} +$build = BuildRepository::getById($id); +if (!$build) { + json_out(['ok' => false, 'error' => 'not found'], 404); +} +$tail = BuildRunner::readLogTail($build['log_path'] ?? null, $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/examples/build_console/backend/public/api/build_trigger.php b/examples/build_console/backend/public/api/build_trigger.php new file mode 100644 index 0000000..3dd302a --- /dev/null +++ b/examples/build_console/backend/public/api/build_trigger.php @@ -0,0 +1,28 @@ + false, 'error' => 'POST required'], 405); +} + +$raw = file_get_contents('php://input') ?: '{}'; +$params = json_decode($raw, true); +if (!is_array($params)) { + $params = $_POST; +} + +$pipelinePath = (string) cfg('build.pipeline_config', '/workspace/build.config.yml'); +$params['pipeline_yaml'] = is_file($pipelinePath) ? (string) file_get_contents($pipelinePath) : null; +$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/examples/build_console/backend/public/api/builds.php b/examples/build_console/backend/public/api/builds.php new file mode 100644 index 0000000..482f1a7 --- /dev/null +++ b/examples/build_console/backend/public/api/builds.php @@ -0,0 +1,5 @@ + true, 'builds' => BuildRepository::listRecent(10)]); diff --git a/examples/build_console/backend/public/api/health.php b/examples/build_console/backend/public/api/health.php new file mode 100644 index 0000000..e289d91 --- /dev/null +++ b/examples/build_console/backend/public/api/health.php @@ -0,0 +1,4 @@ + true, 'health' => BuildRepository::healthSummary()]); diff --git a/examples/build_console/backend/public/assets/js/builder.js b/examples/build_console/backend/public/assets/js/builder.js new file mode 100644 index 0000000..81102f9 --- /dev/null +++ b/examples/build_console/backend/public/assets/js/builder.js @@ -0,0 +1,74 @@ +(function () { + 'use strict'; + var base = document.body.getAttribute('data-base-path') || ''; + 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' }, + body: JSON.stringify(payload) + }).then(function (r) { return r.json(); }).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 () { status.textContent = 'Network error'; }); + }); + } + + var buildId = document.body.getAttribute('data-build-id'); + var logEl = document.getElementById('build-log-tail'); + if (buildId && logEl) { + var offset = 0; + var full = ''; + function poll() { + fetch(base + '/api/build_log.php?id=' + encodeURIComponent(buildId) + '&offset=' + offset) + .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; + logEl.textContent = full.slice(-12000); + logEl.scrollTop = logEl.scrollHeight; + } + if (j.build && j.build.status === 'running') { + setTimeout(poll, 1000); + } + }).catch(function () { setTimeout(poll, 2000); }); + } + poll(); + var expand = document.getElementById('build-log-expand'); + if (expand) { + expand.addEventListener('click', function () { + logEl.textContent = full; + }); + } + } +})(); diff --git a/examples/build_console/backend/public/index.php b/examples/build_console/backend/public/index.php new file mode 100644 index 0000000..e7e88c3 --- /dev/null +++ b/examples/build_console/backend/public/index.php @@ -0,0 +1,77 @@ +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(); + $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 = trim((string) shell_exec('docker info >/dev/null 2>&1 && echo ok')) === 'ok'; + 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/examples/build_console/backend/src/BuildRunner.php b/examples/build_console/backend/src/BuildRunner.php new file mode 100644 index 0000000..b75591c --- /dev/null +++ b/examples/build_console/backend/src/BuildRunner.php @@ -0,0 +1,137 @@ + $buildCode, + 'status' => 'queued', + 'phase' => 'queued', + 'git_ref' => $params['git_ref'] ?? null, + 'git_sha' => $params['git_sha'] ?? null, + 'branch' => $params['branch'] ?? 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, + 'status' => 'running', + 'phase' => 'docker', + 'started_at' => gmdate('Y-m-d H:i:s'), + ]); + + $runner = (string) cfg('build.runner_script', '/workspace/scripts/docker-build-runner.sh'); + $repo = (string) cfg('build.repo_root', '/workspace'); + $env = [ + 'BUILD_ID=' . $id, + 'BUILD_LOG_FILE=' . escapeshellarg($logPath), + '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'] ?? '')), + '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'), + ]; + $cmd = implode(' ', $env) . ' nohup ' . escapeshellarg($runner) . ' >> ' . escapeshellarg($logPath) . ' 2>&1 & echo $!'; + $pid = trim((string) shell_exec($cmd)); + file_put_contents($dir . '/runner.pid', $pid); + + return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode]; + } + + 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 = filesize($logPath) ?: 0; + $offset = max(0, min($offset, $size)); + $fh = fopen($logPath, 'rb'); + if (!$fh) { + return ['offset' => $offset, 'eof' => true, 'text' => '']; + } + fseek($fh, $offset); + $chunk = fread($fh, $maxBytes) ?: ''; + $newOffset = $offset + strlen($chunk); + fclose($fh); + return ['offset' => $newOffset, 'eof' => $newOffset >= $size, 'text' => $chunk]; + } + + 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/examples/build_console/backend/src/bootstrap.php b/examples/build_console/backend/src/bootstrap.php new file mode 100644 index 0000000..676269f --- /dev/null +++ b/examples/build_console/backend/src/bootstrap.php @@ -0,0 +1,54 @@ + +

Build

+

Status: · Phase:

+ +
+
+
Builder
+
CI image
+
Target OS
(min API )
+
Git ref
+
Git SHA
+
OTA channel
+
Gradle task
+
+
+ + +
+

Artifacts

+ +
+ + +
+

Pipeline (YAML)

+
+
+ +
+

Build log

+

+  
+
diff --git a/examples/build_console/backend/views/home.php b/examples/build_console/backend/views/home.php new file mode 100644 index 0000000..b5a6895 --- /dev/null +++ b/examples/build_console/backend/views/home.php @@ -0,0 +1,68 @@ + +

Android Cast Builder

+

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

+ +
+

Ecosystem health

+ +
+ +
+

Run pipeline

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

Recent builds (last 10)

+
+ + + + + + + + + + + + + + + + + + + + + + + +
CodeStatusPhaseBranch/refChannelCreated
+
+
diff --git a/examples/build_console/backend/views/layout.php b/examples/build_console/backend/views/layout.php new file mode 100644 index 0000000..0c7b94c --- /dev/null +++ b/examples/build_console/backend/views/layout.php @@ -0,0 +1,58 @@ + + + + + + + <?= h($pageTitle ?? 'Builder') ?> — <?= h(cfg('app_name')) ?> + + + + + +> +
+ +
+ + + + + +
+
+ + + diff --git a/examples/build_console/backend/views/login.php b/examples/build_console/backend/views/login.php new file mode 100644 index 0000000..a3141ba --- /dev/null +++ b/examples/build_console/backend/views/login.php @@ -0,0 +1,25 @@ + + + + + + + Login — <?= h(cfg('app_name')) ?> + + + +
+

+

Sign in (same credentials as Crashes / Tickets)

+ +
+ + + + +
+ + diff --git a/examples/crash_reporter/backend/config/config.example.php b/examples/crash_reporter/backend/config/config.example.php index f2a7347..34ba5d8 100644 --- a/examples/crash_reporter/backend/config/config.example.php +++ b/examples/crash_reporter/backend/config/config.example.php @@ -30,6 +30,8 @@ return [ ], ], 'session_name' => 'ac_crash_sess', + // Shared login cookie across crashes, build, and other consoles under this path + 'session_cookie_path' => '/app/androidcast_project', // When true, upload API may include a short error hint (do not enable on public prod long-term) 'debug' => false, // Phase 1 RBAC: anonymous uploads and legacy rows attach to this company diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php index 59673a4..d3cf85c 100644 --- a/examples/crash_reporter/backend/src/bootstrap.php +++ b/examples/crash_reporter/backend/src/bootstrap.php @@ -12,6 +12,8 @@ */ declare(strict_types=1); +require_once dirname(__DIR__, 2) . '/platform/shared_session.php'; + if (!function_exists('session_start')) { http_response_code(500); header('Content-Type: text/plain; charset=utf-8'); @@ -27,10 +29,7 @@ if (!is_file($configPath)) { } $config = require $configPath; -session_name($config['session_name'] ?? 'ac_crash_sess'); -if (session_status() !== PHP_SESSION_ACTIVE) { - session_start(); -} +platform_start_session($config['session_name'] ?? 'ac_crash_sess', $config['session_cookie_path'] ?? '/app/androidcast_project'); require_once __DIR__ . '/Database.php'; require_once __DIR__ . '/Rbac.php'; diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php index b7dfa69..6258311 100644 --- a/examples/crash_reporter/backend/views/layout.php +++ b/examples/crash_reporter/backend/views/layout.php @@ -75,6 +75,15 @@ Tickets +
  • + + + Builder + +