mirror of
git://f0xx.org/ac/ac-ms-build
synced 2026-08-12 18:13:16 +03:00
Builder: docker home env + Gitea CI webhook status.
Export HOME/DOCKER_CONFIG for nobody PHP-FPM, improve docker health checks, and report build status to Gitea when enabled. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
32
public/api/build_webhook.php
Normal file
32
public/api/build_webhook.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
json_out(['ok' => false, 'error' => 'POST required'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$secret = trim((string) cfg('build.webhook_secret', ''));
|
||||||
|
$raw = file_get_contents('php://input') ?: '';
|
||||||
|
$sig = (string) ($_SERVER['HTTP_X_GITEA_SIGNATURE'] ?? $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '');
|
||||||
|
if ($secret !== '') {
|
||||||
|
$expected = hash_hmac('sha256', $raw, $secret);
|
||||||
|
if ($sig === '' || !hash_equals($expected, $sig)) {
|
||||||
|
json_out(['ok' => false, 'error' => 'bad_signature'], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode($raw, true);
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$build = BuildRunner::enqueueFromWebhook($payload);
|
||||||
|
json_out(['ok' => true, 'build' => $build]);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
json_out(['ok' => false, 'error' => $e->getMessage()], 422);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('build_webhook: ' . $e->getMessage());
|
||||||
|
json_out(['ok' => false, 'error' => 'enqueue_failed'], 500);
|
||||||
|
}
|
||||||
@@ -109,6 +109,24 @@ SQL);
|
|||||||
return $stmt->fetchAll() ?: [];
|
return $stmt->fetchAll() ?: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<int> */
|
||||||
|
public static function listActiveForBranch(string $branch): array {
|
||||||
|
self::ensureSchema();
|
||||||
|
$branch = trim($branch);
|
||||||
|
if ($branch === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$stmt = Database::pdo()->prepare(
|
||||||
|
"SELECT id FROM builds WHERE branch = ? AND status IN ('queued','running') ORDER BY id ASC"
|
||||||
|
);
|
||||||
|
$stmt->execute([$branch]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||||
|
$out[] = (int) ($row['id'] ?? 0);
|
||||||
|
}
|
||||||
|
return array_values(array_filter($out, static fn (int $id): bool => $id > 0));
|
||||||
|
}
|
||||||
|
|
||||||
public static function healthSummary(): array {
|
public static function healthSummary(): array {
|
||||||
self::ensureSchema();
|
self::ensureSchema();
|
||||||
BuildRunner::reconcileRunningBuilds();
|
BuildRunner::reconcileRunningBuilds();
|
||||||
|
|||||||
@@ -16,15 +16,31 @@ final class BuildRunner {
|
|||||||
return $which !== '' ? $which : 'docker';
|
return $which !== '' ? $which : 'docker';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Writable HOME for docker CLI when PHP-FPM runs as nobody (default HOME=/). */
|
||||||
|
public static function dockerHomeDir(): string {
|
||||||
|
$home = trim((string) cfg('build.docker_home', '/var/www/ac/broadcast'));
|
||||||
|
return $home !== '' ? rtrim($home, '/') : '/var/www/ac/broadcast';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function dockerEnvPrefix(): string {
|
||||||
|
$home = self::dockerHomeDir();
|
||||||
|
$config = $home . '/.docker';
|
||||||
|
if (!is_dir($config)) {
|
||||||
|
@mkdir($config, 0775, true);
|
||||||
|
}
|
||||||
|
return 'HOME=' . escapeshellarg($home) . ' DOCKER_CONFIG=' . escapeshellarg($config) . ' ';
|
||||||
|
}
|
||||||
|
|
||||||
public static function dockerAvailable(): bool {
|
public static function dockerAvailable(): bool {
|
||||||
$bin = escapeshellarg(self::dockerBinary());
|
$bin = escapeshellarg(self::dockerBinary());
|
||||||
return trim((string) shell_exec("{$bin} info >/dev/null 2>&1 && echo ok")) === 'ok';
|
$env = self::dockerEnvPrefix();
|
||||||
|
return trim((string) shell_exec("{$env}{$bin} info >/dev/null 2>&1 && echo ok")) === 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function dockerCheckDetail(): string {
|
public static function dockerCheckDetail(): string {
|
||||||
$bin = self::dockerBinary();
|
$bin = self::dockerBinary();
|
||||||
$sock = '/var/run/docker.sock';
|
$sock = '/var/run/docker.sock';
|
||||||
$parts = ['bin=' . $bin];
|
$parts = ['bin=' . $bin, 'home=' . self::dockerHomeDir()];
|
||||||
if (!is_executable($bin)) {
|
if (!is_executable($bin)) {
|
||||||
$parts[] = 'bin_not_executable';
|
$parts[] = 'bin_not_executable';
|
||||||
}
|
}
|
||||||
@@ -143,7 +159,11 @@ YAML;
|
|||||||
));
|
));
|
||||||
$mobileSub = trim((string) cfg('build.mobile_subdir', 'ac-mobile-android'));
|
$mobileSub = trim((string) cfg('build.mobile_subdir', 'ac-mobile-android'));
|
||||||
$scriptsDir = trim((string) cfg('build.scripts_dir', dirname((string) cfg('build.runner_script', ''))));
|
$scriptsDir = trim((string) cfg('build.scripts_dir', dirname((string) cfg('build.runner_script', ''))));
|
||||||
|
$dockerHome = self::dockerHomeDir();
|
||||||
$env = [
|
$env = [
|
||||||
|
'HOME=' . escapeshellarg($dockerHome),
|
||||||
|
'DOCKER_CONFIG=' . escapeshellarg($dockerHome . '/.docker'),
|
||||||
|
'BUILDER_HOME=' . escapeshellarg($dockerHome),
|
||||||
'BUILD_ID=' . $id,
|
'BUILD_ID=' . $id,
|
||||||
'BUILD_LOG_FILE=' . escapeshellarg($logPath),
|
'BUILD_LOG_FILE=' . escapeshellarg($logPath),
|
||||||
'BUILD_LOG_STDOUT_ONLY=1',
|
'BUILD_LOG_STDOUT_ONLY=1',
|
||||||
@@ -189,7 +209,65 @@ YAML;
|
|||||||
'started_at' => gmdate('Y-m-d H:i:s'),
|
'started_at' => gmdate('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode];
|
$build = BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode];
|
||||||
|
if (GiteaCiStatus::isEnabled() && !empty($params['git_sha'])) {
|
||||||
|
GiteaCiStatus::reportForBuild($build, 'pending', 'Build running');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $build;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel queued/running builds for the same branch (superseded by a newer push). */
|
||||||
|
public static function cancelSupersededForBranch(string $branch): void {
|
||||||
|
$branch = trim($branch);
|
||||||
|
if ($branch === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (BuildRepository::listActiveForBranch($branch) as $id) {
|
||||||
|
$build = BuildRepository::getById($id);
|
||||||
|
if (!$build) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self::stopBuild($id);
|
||||||
|
if (GiteaCiStatus::isEnabled()) {
|
||||||
|
GiteaCiStatus::reportForBuild($build, 'error', 'Superseded by newer commit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $payload Gitea push webhook JSON */
|
||||||
|
public static function enqueueFromWebhook(array $payload): array {
|
||||||
|
$ref = (string) ($payload['ref'] ?? '');
|
||||||
|
$after = (string) ($payload['after'] ?? '');
|
||||||
|
if ($after === '' || str_starts_with($after, '0000000')) {
|
||||||
|
throw new InvalidArgumentException('empty_or_delete_push');
|
||||||
|
}
|
||||||
|
$branch = str_starts_with($ref, 'refs/heads/') ? substr($ref, 11) : $ref;
|
||||||
|
if ($branch === '') {
|
||||||
|
throw new InvalidArgumentException('missing_branch');
|
||||||
|
}
|
||||||
|
$repo = is_array($payload['repository'] ?? null) ? $payload['repository'] : [];
|
||||||
|
$fullName = (string) ($repo['full_name'] ?? cfg('build.gitea_default_repo', 'ac/ac-mobile-android'));
|
||||||
|
[$owner, $repoName] = array_pad(explode('/', $fullName, 2), 2, 'ac-mobile-android');
|
||||||
|
self::cancelSupersededForBranch($branch);
|
||||||
|
$params = [
|
||||||
|
'git_ref' => $branch,
|
||||||
|
'git_sha' => $after,
|
||||||
|
'branch' => $branch,
|
||||||
|
'trigger_source' => 'ci',
|
||||||
|
'gitea_owner' => $owner !== '' ? $owner : 'ac',
|
||||||
|
'gitea_repo' => $repoName,
|
||||||
|
'run_tests' => true,
|
||||||
|
'run_native' => true,
|
||||||
|
'run_apk' => true,
|
||||||
|
'auto_ota' => false,
|
||||||
|
'notify_on_fail' => true,
|
||||||
|
];
|
||||||
|
$build = self::enqueue($params, null);
|
||||||
|
if (GiteaCiStatus::isEnabled()) {
|
||||||
|
GiteaCiStatus::reportForBuild($build, 'pending', 'Build queued');
|
||||||
|
}
|
||||||
|
return $build;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function failBuild(int $id, string $message, ?string $logPath = null): void {
|
public static function failBuild(int $id, string $message, ?string $logPath = null): void {
|
||||||
@@ -205,6 +283,9 @@ YAML;
|
|||||||
]);
|
]);
|
||||||
$build = BuildRepository::getById($id);
|
$build = BuildRepository::getById($id);
|
||||||
if ($build) {
|
if ($build) {
|
||||||
|
if (GiteaCiStatus::isEnabled()) {
|
||||||
|
GiteaCiStatus::reportForBuild($build, 'failure', substr($safe, 0, 120));
|
||||||
|
}
|
||||||
BuildNotifier::onFailure($id, $build, $safe);
|
BuildNotifier::onFailure($id, $build, $safe);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -544,8 +625,15 @@ YAML;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
BuildRepository::update($buildId, $update);
|
BuildRepository::update($buildId, $update);
|
||||||
|
$build = BuildRepository::getById($buildId);
|
||||||
|
if ($build && GiteaCiStatus::isEnabled()) {
|
||||||
|
if ($status === 'passed') {
|
||||||
|
GiteaCiStatus::reportForBuild($build, 'success', 'Build passed');
|
||||||
|
} else {
|
||||||
|
GiteaCiStatus::reportForBuild($build, 'failure', 'Build failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
if ($status === 'failed') {
|
if ($status === 'failed') {
|
||||||
$build = BuildRepository::getById($buildId);
|
|
||||||
if ($build) {
|
if ($build) {
|
||||||
BuildNotifier::onFailure($buildId, $build, (string) ($update['error_message'] ?? 'Build failed'));
|
BuildNotifier::onFailure($buildId, $build, (string) ($update['error_message'] ?? 'Build failed'));
|
||||||
}
|
}
|
||||||
|
|||||||
77
src/GiteaCiStatus.php
Normal file
77
src/GiteaCiStatus.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** Posts commit statuses to Gitea for merge gating (context: androidcast/build). */
|
||||||
|
final class GiteaCiStatus {
|
||||||
|
public const CONTEXT = 'androidcast/build';
|
||||||
|
|
||||||
|
public static function isEnabled(): bool {
|
||||||
|
return trim((string) cfg('build.gitea_api_url', '')) !== ''
|
||||||
|
&& trim((string) cfg('build.gitea_token', '')) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param 'pending'|'success'|'failure'|'error' $state */
|
||||||
|
public static function report(
|
||||||
|
string $owner,
|
||||||
|
string $repo,
|
||||||
|
string $sha,
|
||||||
|
string $state,
|
||||||
|
string $description = '',
|
||||||
|
?string $targetUrl = null
|
||||||
|
): void {
|
||||||
|
if (!self::isEnabled() || $sha === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$api = rtrim((string) cfg('build.gitea_api_url'), '/');
|
||||||
|
$token = (string) cfg('build.gitea_token');
|
||||||
|
$url = $api . '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo)
|
||||||
|
. '/statuses/' . rawurlencode($sha);
|
||||||
|
$payload = json_encode([
|
||||||
|
'state' => $state,
|
||||||
|
'target_url' => $targetUrl ?? self::defaultTargetUrl(),
|
||||||
|
'description' => substr($description, 0, 240),
|
||||||
|
'context' => self::CONTEXT,
|
||||||
|
], JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($payload === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$ch = curl_init($url);
|
||||||
|
if ($ch === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Authorization: token ' . $token,
|
||||||
|
'Content-Type: application/json',
|
||||||
|
],
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_TIMEOUT => 12,
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function defaultTargetUrl(): string {
|
||||||
|
$base = rtrim((string) cfg('build.public_base_url', ''), '/');
|
||||||
|
return $base !== '' ? $base . '/build/' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $build */
|
||||||
|
public static function reportForBuild(array $build, string $state, string $description = ''): void {
|
||||||
|
$params = json_decode((string) ($build['params_json'] ?? '{}'), true);
|
||||||
|
if (!is_array($params)) {
|
||||||
|
$params = [];
|
||||||
|
}
|
||||||
|
$owner = (string) ($params['gitea_owner'] ?? cfg('build.gitea_owner', 'ac'));
|
||||||
|
$repo = (string) ($params['gitea_repo'] ?? cfg('build.gitea_repo', 'ac-mobile-android'));
|
||||||
|
$sha = (string) ($build['git_sha'] ?? '');
|
||||||
|
$id = (int) ($build['id'] ?? 0);
|
||||||
|
$target = self::defaultTargetUrl();
|
||||||
|
if ($id > 0 && $target !== '') {
|
||||||
|
$target .= '?build=' . $id;
|
||||||
|
}
|
||||||
|
self::report($owner, $repo, $sha, $state, $description, $target);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ foreach ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_once __DIR__ . '/BuildRepository.php';
|
require_once __DIR__ . '/BuildRepository.php';
|
||||||
|
require_once __DIR__ . '/GiteaCiStatus.php';
|
||||||
require_once __DIR__ . '/BuildRunner.php';
|
require_once __DIR__ . '/BuildRunner.php';
|
||||||
|
|
||||||
function cfg(string $key, $default = null) {
|
function cfg(string $key, $default = null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user