diff --git a/config/config.example.php b/config/config.example.php
index 953a1c2..2fa1028 100644
--- a/config/config.example.php
+++ b/config/config.example.php
@@ -37,6 +37,14 @@ return [
// Repo copy preferred; generated YAML used if missing on builder host
'pipeline_config' => '/workspace/build.config.yml',
'default_channels' => ['stable', 'staging', 'dev', 'nightly'],
+ 'mobile_subdir' => 'ac-mobile-android',
+ 'scripts_dir' => '/workspace/ac-scripts',
+ 'notify' => [
+ 'admin_email' => 'bestcastr@gmail.com',
+ 'broadcast_config' => '/var/www/ac/workspace/ac-ms-broadcast/config/config.php',
+ 'telegram_token' => '',
+ 'telegram_chat_id' => '',
+ ],
],
'links' => [
'hub' => '/app/androidcast_project/',
diff --git a/deps/ac-ms-build b/deps/ac-ms-build
index 27e1f7d..b8000a7 160000
--- a/deps/ac-ms-build
+++ b/deps/ac-ms-build
@@ -1 +1 @@
-Subproject commit 27e1f7d6c7642cc0a0faff701f2be30d15925c2b
+Subproject commit b8000a712f6b313403ddc95c27f824b594f157c6
diff --git a/deps/ac-platform-php b/deps/ac-platform-php
index f4b39be..5159292 160000
--- a/deps/ac-platform-php
+++ b/deps/ac-platform-php
@@ -1 +1 @@
-Subproject commit f4b39bea26d1227249e05c6c91a15b73e0e77641
+Subproject commit 515929238baae0214e910884c248b73a9caed953
diff --git a/public/api/build_trigger.php b/public/api/build_trigger.php
index be05f77..2c13268 100644
--- a/public/api/build_trigger.php
+++ b/public/api/build_trigger.php
@@ -21,6 +21,15 @@ $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';
+$params['notify_on_fail'] = array_key_exists('notify_on_fail', $params) ? !empty($params['notify_on_fail']) : true;
+$params['notify_channel'] = in_array(
+ (string) ($params['notify_channel'] ?? 'email'),
+ ['email', 'telegram', 'both'],
+ true
+) ? (string) $params['notify_channel'] : 'email';
+if (!isset($params['trigger_source'])) {
+ $params['trigger_source'] = 'manual';
+}
$user = Auth::user();
$build = BuildRunner::enqueue($params, isset($user['id']) ? (int) $user['id'] : null);
diff --git a/public/assets/js/builder.js b/public/assets/js/builder.js
index 2747e30..3dbb36c 100644
--- a/public/assets/js/builder.js
+++ b/public/assets/js/builder.js
@@ -139,7 +139,10 @@
run_native: !!form.querySelector('[name=run_native]').checked,
run_apk: !!form.querySelector('[name=run_apk]').checked,
auto_ota: !!form.querySelector('[name=auto_ota]').checked,
- auto_deploy: !!form.querySelector('[name=auto_deploy]').checked
+ auto_deploy: !!form.querySelector('[name=auto_deploy]').checked,
+ notify_on_fail: !!form.querySelector('[name=notify_on_fail]').checked,
+ notify_channel: fd.get('notify_channel') || 'email',
+ trigger_source: 'manual'
};
status.textContent = 'Starting…';
fetch(base + '/api/build_trigger.php', {
diff --git a/src/BuildErrorSanitizer.php b/src/BuildErrorSanitizer.php
new file mode 100644
index 0000000..5825574
--- /dev/null
+++ b/src/BuildErrorSanitizer.php
@@ -0,0 +1,28 @@
+]+#i', '***', $out) ?? $out;
+ $out = preg_replace('#git://[^\s\'"<>]+#i', '***', $out) ?? $out;
+ $out = preg_replace('#ssh://[^\s\'"<>]+#i', '***', $out) ?? $out;
+ $out = preg_replace(
+ '/\b(password|passwd|pass|token|secret|api[_-]?key|bearer|authorization)\s*[:=]\s*\S+/i',
+ '$1=***',
+ $out
+ ) ?? $out;
+ $out = preg_replace(
+ '/(?:\/[^\s]+)?\/(?:\.env|credentials(?:\.json)?|secrets?\.(?:json|ya?ml|env))[^\s]*/i',
+ '***',
+ $out
+ ) ?? $out;
+ $out = preg_replace('#/var/www/[^\s\'"]+#', '***', $out) ?? $out;
+ $out = preg_replace('#/home/[^\s\'"]+#', '***', $out) ?? $out;
+ return trim(preg_replace('/\s{2,}/', ' ', $out) ?? $out);
+ }
+}
diff --git a/src/BuildNotifier.php b/src/BuildNotifier.php
new file mode 100644
index 0000000..942ff42
--- /dev/null
+++ b/src/BuildNotifier.php
@@ -0,0 +1,236 @@
+ $build */
+ public static function onFailure(int $buildId, array $build, string $errorMessage): void {
+ $params = json_decode((string) ($build['params_json'] ?? '{}'), true);
+ if (!is_array($params)) {
+ $params = [];
+ }
+ $trigger = (string) ($params['trigger_source'] ?? 'manual');
+ $safeError = BuildErrorSanitizer::sanitize($errorMessage);
+ $code = (string) ($build['build_code'] ?? ('#' . $buildId));
+ $ref = (string) ($build['git_ref'] ?? $build['branch'] ?? '—');
+ $basePath = rtrim((string) cfg('base_path', '/app/androidcast_project/build'), '/');
+ $detailUrl = self::publicBaseUrl() . $basePath . '/?view=build&id=' . $buildId;
+ $subject = 'Android Cast build failed: ' . $code;
+ $bodyText = "Build {$code} failed (ref: {$ref}).\n\n"
+ . $safeError . "\n\n"
+ . "Details: {$detailUrl}\n";
+
+ if (in_array($trigger, ['autobuild', 'agent', 'ci'], true)) {
+ self::notifyAdmin($subject, $bodyText, $code, $safeError, $detailUrl);
+ return;
+ }
+
+ if (empty($params['notify_on_fail'])) {
+ return;
+ }
+
+ $channel = (string) ($params['notify_channel'] ?? 'email');
+ $userId = (int) ($build['created_by_user_id'] ?? 0);
+ $username = self::usernameFor($userId);
+ $userPrefix = $username !== '' ? "User {$username}: " : '';
+
+ if ($channel === 'telegram' || $channel === 'both') {
+ self::sendTelegram(
+ $userPrefix . $subject,
+ $bodyText,
+ self::telegramConfig()
+ );
+ }
+ if ($channel === 'email' || $channel === 'both') {
+ $to = self::emailForUser($userId);
+ if ($to !== '') {
+ self::sendPlainEmail($to, $subject, nl2br(h($bodyText)), self::smtpConfig());
+ }
+ }
+ }
+
+ private static function notifyAdmin(string $subject, string $bodyText, string $code, string $safeError, string $detailUrl): void {
+ $tg = self::telegramConfig();
+ if ($tg['token'] !== '' && $tg['chat_id'] !== '') {
+ self::sendTelegram($subject, $bodyText, $tg);
+ }
+ $admin = (string) cfg('build.notify.admin_email', 'bestcastr@gmail.com');
+ if ($admin !== '') {
+ $html = '
' . h($code) . ' failed.
'
+ . '' . h($safeError) . '
'
+ . 'Open build log
';
+ self::sendPlainEmail($admin, $subject, $html, self::smtpConfig());
+ }
+ }
+
+ /** @return array{token:string,chat_id:string} */
+ private static function telegramConfig(): array {
+ $token = trim((string) cfg('build.notify.telegram_token', ''));
+ $chatId = trim((string) cfg('build.notify.telegram_chat_id', ''));
+ if ($token === '' || $chatId === '') {
+ $bc = self::loadBroadcastConfig();
+ $token = $token !== '' ? $token : trim((string) ($bc['telegram_token'] ?? ''));
+ $chatId = $chatId !== '' ? $chatId : trim((string) ($bc['telegram_chat_id'] ?? ''));
+ }
+ return ['token' => $token, 'chat_id' => $chatId];
+ }
+
+ /** @return array */
+ private static function smtpConfig(): array {
+ $mail = cfg('mail', []);
+ if (is_array($mail) && !empty($mail['smtp'])) {
+ $smtp = $mail['smtp'];
+ return [
+ 'host' => (string) ($smtp['host'] ?? 'smtp.gmail.com'),
+ 'port' => (int) ($smtp['port'] ?? 587),
+ 'user' => (string) ($smtp['user'] ?? $smtp['username'] ?? ''),
+ 'pass' => (string) ($smtp['password'] ?? $smtp['pass'] ?? ''),
+ 'from' => (string) ($mail['from'] ?? $smtp['user'] ?? ''),
+ 'from_name' => (string) ($mail['from_name'] ?? 'AndroidCast Builder'),
+ ];
+ }
+ $bc = self::loadBroadcastConfig();
+ return [
+ 'host' => (string) ($bc['smtp_host'] ?? 'smtp.gmail.com'),
+ 'port' => (int) ($bc['smtp_port'] ?? 587),
+ 'user' => (string) ($bc['smtp_user'] ?? ''),
+ 'pass' => (string) ($bc['smtp_password'] ?? ''),
+ 'from' => (string) ($bc['smtp_from'] ?? $bc['smtp_user'] ?? ''),
+ 'from_name' => (string) ($bc['smtp_from_name'] ?? 'AndroidCast Builder'),
+ ];
+ }
+
+ /** @return array */
+ private static function loadBroadcastConfig(): array {
+ static $cached = null;
+ if (is_array($cached)) {
+ return $cached;
+ }
+ $path = (string) cfg(
+ 'build.notify.broadcast_config',
+ '/var/www/ac/workspace/ac-ms-broadcast/config/config.php'
+ );
+ if ($path !== '' && is_file($path)) {
+ $cfg = require $path;
+ $cached = is_array($cfg) ? $cfg : [];
+ return $cached;
+ }
+ $cached = [];
+ return $cached;
+ }
+
+ private static function publicBaseUrl(): string {
+ $host = $_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? 'apps.f0xx.org';
+ $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
+ || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
+ ? 'https' : 'http';
+ return $scheme . '://' . explode(',', (string) $host)[0];
+ }
+
+ private static function emailForUser(int $userId): string {
+ if ($userId <= 0) {
+ return '';
+ }
+ $stmt = Database::pdo()->prepare(
+ 'SELECT email_normalized, username FROM users WHERE id = ? LIMIT 1'
+ );
+ $stmt->execute([$userId]);
+ $row = $stmt->fetch();
+ if (!$row) {
+ return '';
+ }
+ $email = trim((string) ($row['email_normalized'] ?? ''));
+ if ($email !== '' && str_contains($email, '@')) {
+ return $email;
+ }
+ return '';
+ }
+
+ private static function usernameFor(int $userId): string {
+ if ($userId <= 0) {
+ return '';
+ }
+ $stmt = Database::pdo()->prepare('SELECT username FROM users WHERE id = ? LIMIT 1');
+ $stmt->execute([$userId]);
+ $row = $stmt->fetch();
+ return $row ? (string) ($row['username'] ?? '') : '';
+ }
+
+ /** @param array{token:string,chat_id:string} $tg */
+ private static function sendTelegram(string $title, string $body, array $tg): void {
+ if ($tg['token'] === '' || $tg['chat_id'] === '') {
+ return;
+ }
+ $text = '' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n\n"
+ . htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ $payload = http_build_query([
+ 'chat_id' => $tg['chat_id'],
+ 'text' => $text,
+ 'parse_mode' => 'HTML',
+ 'disable_web_page_preview' => '1',
+ ]);
+ $ctx = stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
+ 'content' => $payload,
+ 'timeout' => 15,
+ 'ignore_errors' => true,
+ ],
+ ]);
+ @file_get_contents(
+ 'https://api.telegram.org/bot' . $tg['token'] . '/sendMessage',
+ false,
+ $ctx
+ );
+ }
+
+ /** @param array $smtp */
+ private static function sendPlainEmail(string $to, string $subject, string $htmlBody, array $smtp): void {
+ if ($to === '' || empty($smtp['user']) || empty($smtp['pass'])) {
+ return;
+ }
+ $host = (string) ($smtp['host'] ?? 'smtp.gmail.com');
+ $port = (int) ($smtp['port'] ?? 587);
+ $from = (string) ($smtp['from'] ?? $smtp['user']);
+ $fromName = (string) ($smtp['from_name'] ?? 'AndroidCast Builder');
+ $boundary = bin2hex(random_bytes(8));
+ $headers = [
+ "From: {$fromName} <{$from}>",
+ "To: {$to}",
+ 'Subject: =?UTF-8?B?' . base64_encode($subject) . '?=',
+ 'MIME-Version: 1.0',
+ "Content-Type: multipart/alternative; boundary=\"{$boundary}\"",
+ ];
+ $plain = strip_tags(str_replace(['
', '
', '
'], "\n", $htmlBody));
+ $body = "--{$boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{$plain}\r\n"
+ . "--{$boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{$htmlBody}\r\n"
+ . "--{$boundary}--";
+ $sock = @fsockopen("tls://{$host}", $port, $errno, $errstr, 15);
+ if (!$sock) {
+ return;
+ }
+ $read = static fn(): string => (string) fgets($sock, 1024);
+ $send = static function (string $cmd) use ($sock): void {
+ fwrite($sock, $cmd . "\r\n");
+ };
+ $read();
+ $send('EHLO builder.local');
+ $read();
+ $send('AUTH LOGIN');
+ $read();
+ $send(base64_encode((string) $smtp['user']));
+ $read();
+ $send(base64_encode((string) $smtp['pass']));
+ $read();
+ $send("MAIL FROM:<{$from}>");
+ $read();
+ $send("RCPT TO:<{$to}>");
+ $read();
+ $send('DATA');
+ $read();
+ fwrite($sock, implode("\r\n", $headers) . "\r\n\r\n" . $body . "\r\n.\r\n");
+ $send('QUIT');
+ fclose($sock);
+ }
+}
diff --git a/src/BuildRunner.php b/src/BuildRunner.php
index e9ed74c..982252c 100644
--- a/src/BuildRunner.php
+++ b/src/BuildRunner.php
@@ -94,6 +94,9 @@ YAML;
public static function enqueue(array $params, ?int $userId): array {
BuildRepository::ensureSchema();
+ if (!isset($params['trigger_source'])) {
+ $params['trigger_source'] = $userId !== null ? 'manual' : 'agent';
+ }
$params['pipeline_yaml'] = $params['pipeline_yaml'] ?? self::resolvePipelineYaml($params);
$buildCode = self::generateBuildCode();
$id = BuildRepository::create([
@@ -138,6 +141,8 @@ YAML;
. ' -C ' . escapeshellarg($repo)
. ' remote get-url origin 2>/dev/null'
));
+ $mobileSub = trim((string) cfg('build.mobile_subdir', 'ac-mobile-android'));
+ $scriptsDir = trim((string) cfg('build.scripts_dir', dirname((string) cfg('build.runner_script', ''))));
$env = [
'BUILD_ID=' . $id,
'BUILD_LOG_FILE=' . escapeshellarg($logPath),
@@ -146,6 +151,9 @@ YAML;
'BUILD_GIT_DEPTH=' . max(1, (int) cfg('build.git_clone_depth', 1)),
'BUILD_WORK_DIR=' . escapeshellarg($repo),
'BUILD_OUT_DIR=' . escapeshellarg($dir),
+ 'BUILD_MOBILE_SUBDIR=' . escapeshellarg($mobileSub),
+ 'BUILD_SCRIPTS_DIR=' . escapeshellarg($scriptsDir !== '' ? $scriptsDir : dirname($runner)),
+ 'BUILD_DOCKERFILE=' . escapeshellarg((string) cfg('build.dockerfile', '')),
'ANDROIDCAST_CI_VERSION=' . escapeshellarg((string) cfg('build.ci_version', '00.01.00.1000')),
'GIT_REF=' . escapeshellarg((string) ($params['git_ref'] ?? '')),
'GIT_SHA=' . escapeshellarg((string) ($params['git_sha'] ?? '')),
@@ -185,15 +193,20 @@ YAML;
}
public static function failBuild(int $id, string $message, ?string $logPath = null): void {
+ $safe = BuildErrorSanitizer::sanitize($message);
if ($logPath) {
- file_put_contents($logPath, "\n[builder] ERROR: {$message}\n", FILE_APPEND);
+ file_put_contents($logPath, "\n[builder] ERROR: {$safe}\n", FILE_APPEND);
}
BuildRepository::update($id, [
'status' => 'failed',
'phase' => 'error',
- 'error_message' => substr($message, 0, 512),
+ 'error_message' => substr($safe, 0, 512),
'finished_at' => gmdate('Y-m-d H:i:s'),
]);
+ $build = BuildRepository::getById($id);
+ if ($build) {
+ BuildNotifier::onFailure($id, $build, $safe);
+ }
}
public static function stopBuild(int $id): bool {
@@ -276,6 +289,14 @@ YAML;
/** CircleCI-style: resume from the step that failed (git → docker-build → docker-run). */
public static function detectFailedStep(int $buildId): string {
+ $build = BuildRepository::getById($buildId);
+ if ($build) {
+ $err = (string) ($build['error_message'] ?? '');
+ $started = (string) ($build['started_at'] ?? '');
+ if ($started === '' && str_contains($err, 'Docker daemon not available')) {
+ return 'docker-setup';
+ }
+ }
$dir = self::artifactDir($buildId);
$logTail = '';
$logPath = $dir . '/build.log';
@@ -456,7 +477,7 @@ YAML;
if (strlen($summary) > 480) {
$summary = '…' . substr($summary, -477);
}
- return 'Build runner exited unexpectedly: ' . $summary;
+ return BuildErrorSanitizer::sanitize('Build runner exited unexpectedly: ' . $summary);
}
public static function readLogTail(?string $logPath, int $offset = 0, int $maxBytes = 65536): array {
@@ -508,13 +529,27 @@ YAML;
$versionApp = $info['gitSha'] ?? null;
}
}
- BuildRepository::update($buildId, [
+ $update = [
'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 ($status === 'failed') {
+ $update['error_message'] = substr(
+ BuildErrorSanitizer::sanitize('Build finished without APK artifact'),
+ 0,
+ 512
+ );
+ }
+ BuildRepository::update($buildId, $update);
+ if ($status === 'failed') {
+ $build = BuildRepository::getById($buildId);
+ if ($build) {
+ BuildNotifier::onFailure($buildId, $build, (string) ($update['error_message'] ?? 'Build failed'));
+ }
+ }
if (!empty($artifacts['ota']) && cfg('build.ota_mount')) {
$build = BuildRepository::getById($buildId);
if (!empty($build['auto_deploy'])) {
@@ -533,11 +568,86 @@ YAML;
return;
}
$build = BuildRepository::getById($buildId);
- $channel = $build['ota_channel'] ?? 'staging';
+ $channel = (string) ($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'));
+ $channelJson = $src . '/ota/channel/' . $channel . '.json';
+ if (is_file($channelJson)) {
+ shell_exec(
+ 'mkdir -p '
+ . escapeshellarg($dest . '/ota/channel')
+ . ' && cp -a '
+ . escapeshellarg($channelJson)
+ . ' '
+ . escapeshellarg($dest . '/ota/channel/' . $channel . '.json')
+ );
}
+ self::publishBrowserApk($src, $channel);
+ }
+
+ /** Copy installable .apk to hub /v0/downloads/ for browser sideload. */
+ private static function publishBrowserApk(string $otaV0Dir, string $channel): void {
+ $downloads = rtrim((string) cfg('build.downloads_mount', ''), '/');
+ if ($downloads === '') {
+ $parent = dirname(rtrim((string) cfg('build.ota_mount', ''), '/'));
+ $downloads = $parent !== '' && $parent !== '.'
+ ? $parent . '/downloads'
+ : '/var/www/localhost/htdocs/apps/app/androidcast_project/downloads';
+ }
+ if (!is_dir($downloads) && !@mkdir($downloads, 0775, true) && !is_dir($downloads)) {
+ return;
+ }
+ $apkPath = self::resolveBrowserApkPath($otaV0Dir, $channel);
+ if ($apkPath === null || !is_file($apkPath)) {
+ return;
+ }
+ $latest = $downloads . '/android_cast-latest.apk';
+ $versioned = $downloads . '/' . basename($apkPath);
+ @copy($apkPath, $latest);
+ @copy($apkPath, $versioned);
+ @chown($latest, 'nginx');
+ @chgrp($latest, 'nginx');
+ @chown($versioned, 'nginx');
+ @chgrp($versioned, 'nginx');
+ }
+
+ private static function resolveBrowserApkPath(string $otaV0Dir, string $channel): ?string {
+ $channelFile = $otaV0Dir . '/ota/channel/' . $channel . '.json';
+ if (is_file($channelFile)) {
+ $channelData = json_decode((string) file_get_contents($channelFile), true);
+ $manifestUrl = is_array($channelData) ? (string) ($channelData['manifestUrl'] ?? '') : '';
+ if ($manifestUrl !== '') {
+ $manifestPath = self::otaUrlToLocalPath($manifestUrl, $otaV0Dir);
+ if ($manifestPath !== null && is_file($manifestPath)) {
+ $manifest = json_decode((string) file_get_contents($manifestPath), true);
+ if (is_array($manifest)) {
+ $browser = (string) ($manifest['browserApkUrl'] ?? '');
+ if ($browser !== '') {
+ $local = self::otaUrlToLocalPath($browser, $otaV0Dir);
+ if ($local !== null && is_file($local)) {
+ return $local;
+ }
+ }
+ }
+ }
+ }
+ }
+ $glob = glob($otaV0Dir . '/ota/*/*/*/*.apk') ?: [];
+ if ($glob !== []) {
+ usort($glob, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a));
+ return $glob[0];
+ }
+ return null;
+ }
+
+ private static function otaUrlToLocalPath(string $url, string $otaV0Dir): ?string {
+ $path = (string) parse_url($url, PHP_URL_PATH);
+ $prefix = '/v0/ota/';
+ if (!str_starts_with($path, $prefix)) {
+ return null;
+ }
+ $rel = substr($path, strlen($prefix));
+ $local = $otaV0Dir . '/ota/' . $rel;
+ return is_file($local) ? $local : null;
}
}
diff --git a/src/bootstrap.php b/src/bootstrap.php
index b9db104..42bee6f 100644
--- a/src/bootstrap.php
+++ b/src/bootstrap.php
@@ -75,6 +75,8 @@ foreach ([
}
require_once __DIR__ . '/BuildRepository.php';
+require_once __DIR__ . '/BuildErrorSanitizer.php';
+require_once __DIR__ . '/BuildNotifier.php';
require_once __DIR__ . '/BuildRunner.php';
function cfg(string $key, $default = null) {
diff --git a/views/build_detail.php b/views/build_detail.php
index bb40e4a..684598f 100644
--- a/views/build_detail.php
+++ b/views/build_detail.php
@@ -15,6 +15,7 @@ if ($pipelineYaml === '') {
}
$stepLabels = [
'git' => 'Git checkout / shallow clone',
+ 'docker-setup' => 'Builder host (Docker daemon)',
'docker-build' => 'Docker image build',
'docker-run' => 'Gradle pipeline (docker run)',
];
diff --git a/views/home.php b/views/home.php
index f6a6970..e99e86a 100644
--- a/views/home.php
+++ b/views/home.php
@@ -32,6 +32,14 @@
+
+
+
+
diff --git a/views/layout.php b/views/layout.php
index 5711866..0b54825 100644
--- a/views/layout.php
+++ b/views/layout.php
@@ -1,6 +1,11 @@
@@ -17,6 +22,7 @@ $links = cfg('links', []);
})();
+
@@ -25,28 +31,24 @@ $links = cfg('links', []);
= (($view ?? '') === 'build' && !empty($build['id'])) ? ' data-build-id="' . (int) $build['id'] . '"' : '' ?>
= (($view ?? '') === 'build' && !empty($build['status'])) ? ' data-build-status="' . h((string) $build['status']) . '"' : '' ?>>
-
+ $projectBase,
+ 'issues_base' => $issuesBase,
+ 'active' => $navActive,
+ 'username' => $navUser,
+ ]); ?>
+