Add tests for GitHub commit timestamp handling and runtime channel selection in ReleaseManager. Extend release URL normalization and runtime channel methods, and introduce assignment subject searches.

This commit is contained in:
Jeppe Bundgaard
2026-05-20 11:27:29 +02:00
parent c2263b8c98
commit 24ac681365
3 changed files with 743 additions and 33 deletions
+545 -32
View File
@@ -2,6 +2,7 @@
namespace classes;
use customers\economicCustomers;
use RuntimeException;
use Throwable;
@@ -307,14 +308,16 @@ class release_manager
$this->ensureSchema();
$channel = $this->defaultChannel();
$versions = $this->currentVersionsForChannel((int)$channel['id']);
$urls = $this->releaseRuntimeUrls($channel, $versions);
return [
'generated_at' => date('c'),
'trace_id' => $this->requestTraceId(),
'channel' => $this->publicChannel($channel),
'channel' => $this->publicRuntimeChannel($channel),
'versions' => $versions,
'api_base_url' => $channel['api_base_url'] ?? null,
'frontend_base_url' => $channel['frontend_base_url'] ?? null,
'frontend_base_url' => $urls['frontend_base_url'],
'api_base_url' => $urls['api_base_url'],
'urls' => $urls,
'availability' => $this->channelAvailability($channel),
'capture_policy' => [
'enabled' => false,
@@ -322,10 +325,12 @@ class release_manager
'all_failure_metadata' => true,
'retention_days' => (int)($channel['retention_days'] ?? 14),
],
'available_channels' => $this->publicRuntimeChannelOptions([$channel]),
'selected_channel_slug' => (string)($channel['slug'] ?? ''),
];
}
public function runtimeForPayload(array $payload): array
public function runtimeForPayload(array $payload, array $input = []): array
{
$this->ensureSchema();
@@ -335,38 +340,60 @@ class release_manager
'customer_number' => isset($payload['customer_number']) ? (int)$payload['customer_number'] : null,
];
$channel = $this->resolveChannel($context);
$resolvedChannel = $this->resolveChannel($context);
$availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel);
$channel = $this->chooseRuntimeChannel(
$resolvedChannel,
$availableChannels,
$this->requestedRuntimeChannelSlug($input)
);
$versions = $this->currentVersionsForChannel((int)$channel['id']);
$capturePolicy = $this->capturePolicyFor($context, $channel);
$urls = $this->releaseRuntimeUrls($channel, $versions);
return [
'generated_at' => date('c'),
'trace_id' => $this->requestTraceId(),
'channel' => $this->publicChannel($channel),
'channel' => $this->publicRuntimeChannel($channel),
'versions' => $versions,
'api_base_url' => $channel['api_base_url'] ?? null,
'frontend_base_url' => $channel['frontend_base_url'] ?? null,
'frontend_base_url' => $urls['frontend_base_url'],
'api_base_url' => $urls['api_base_url'],
'urls' => $urls,
'availability' => $this->channelAvailability($channel),
'capture_policy' => $capturePolicy,
'available_channels' => $this->publicRuntimeChannelOptions($availableChannels),
'selected_channel_slug' => (string)($channel['slug'] ?? ''),
'module_keys' => self::MODULE_KEYS,
];
}
public function runtimeForCurrentPrincipal(): array
public function runtimeForCurrentPrincipal(array $input = []): array
{
$this->ensureSchema();
$context = $this->currentPrincipalContext();
$channel = $this->resolveChannel($context);
$resolvedChannel = $this->resolveChannel($context);
$availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel);
$channel = $this->chooseRuntimeChannel(
$resolvedChannel,
$availableChannels,
$this->requestedRuntimeChannelSlug($input)
);
$versions = $this->currentVersionsForChannel((int)$channel['id']);
$urls = $this->releaseRuntimeUrls($channel, $versions);
return [
'generated_at' => date('c'),
'trace_id' => $this->requestTraceId(),
'channel' => $this->publicChannel($channel),
'versions' => $this->currentVersionsForChannel((int)$channel['id']),
'api_base_url' => $channel['api_base_url'] ?? null,
'frontend_base_url' => $channel['frontend_base_url'] ?? null,
'channel' => $this->publicRuntimeChannel($channel),
'versions' => $versions,
'frontend_base_url' => $urls['frontend_base_url'],
'api_base_url' => $urls['api_base_url'],
'urls' => $urls,
'availability' => $this->channelAvailability($channel),
'capture_policy' => $this->capturePolicyFor($context, $channel),
'available_channels' => $this->publicRuntimeChannelOptions($availableChannels),
'selected_channel_slug' => (string)($channel['slug'] ?? ''),
'module_keys' => self::MODULE_KEYS,
];
}
@@ -723,6 +750,38 @@ class release_manager
);
}
public function searchAssignmentSubjects(array $input): array
{
$query = self::normalizeAssignmentSubjectSearch($input['search'] ?? $input['query'] ?? '');
if ($query === '') {
return [];
}
$limit = self::normalizeAssignmentSubjectLimit($input['limit'] ?? 5);
$subjects = array_merge(
$this->searchAssignmentUsers($query, $limit),
$this->searchAssignmentSubusers($query, $limit),
$this->searchAssignmentCustomers($query, $limit)
);
$seen = [];
$normalized = [];
foreach ($subjects as $subject) {
$item = self::publicAssignmentSubjectSuggestion($subject);
if ($item === null) {
continue;
}
$key = $item['subject_type'] . ':' . $item['subject_id'];
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$normalized[] = $item;
}
return $normalized;
}
public function createAssignment(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
@@ -1435,6 +1494,35 @@ class release_manager
];
}
public function setChannelBundle(int $channelId, array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$channel = $this->getChannel($channelId);
$bundleId = $this->nullablePositiveInt($input['bundle_id'] ?? null);
if ($bundleId === null) {
throw new RuntimeException('A release bundle is required.');
}
$bundle = $this->getBundle($bundleId);
if ((int)$bundle['channel_id'] !== (int)$channel['id']) {
throw new RuntimeException('Release bundle does not belong to this channel.');
}
$status = strtolower((string)($bundle['status'] ?? ''));
if (!in_array($status, ['deployed', 'promoted', 'active'], true)) {
throw new RuntimeException('Only deployed release bundles can be set on a channel.');
}
$previous = $this->currentChannelVersionRow($channelId);
$result = $this->promoteBundle($bundleId, $actorUserId);
$this->audit($channelId, null, 'channel_bundle_set', $actorUserId, 'info', [
'bundle_id' => $bundleId,
'previous_bundle_id' => $this->nullablePositiveInt($previous['bundle_id'] ?? null),
]);
return $result;
}
public function listDeployments(int $limit = 50): array
{
if (!release_manager_schema_bootstrap::tablesExist()) {
@@ -1488,7 +1576,11 @@ class release_manager
}
}
$versionLabel = trim((string)($input['version_label'] ?? $input['tag'] ?? $commitSha ?? date('Ymd-His'))) ?: date('Ymd-His');
$deployedUrl = trim((string)($input['deployed_url'] ?? $target['health_url'] ?? '')) ?: null;
$targetPublicUrl = is_array($target) ? $this->releaseTargetPublicBaseUrl($target) : null;
$deployedUrl = $this->normalizeReleasePublicBaseUrl(
$input['deployed_url'] ?? $targetPublicUrl ?? $target['health_url'] ?? null,
$app
);
$versionId = $this->nullablePositiveInt($input['version_id'] ?? null);
if ($versionId !== null) {
@@ -1568,18 +1660,23 @@ class release_manager
$result = $this->deployCoolifyReleaseTarget($coolifyTarget);
$status = 'deployed';
}
$effectiveDeployedUrl = $this->normalizeReleasePublicBaseUrl($result['public_url'] ?? $deployedUrl, $app);
$this->execute(
"UPDATE release_deployments
SET status = ?, result_json = ?, deployment_url = ?, completed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE NULL END
WHERE id = ?",
'ssssi',
[$status, self::jsonEncode(self::redactPayload($result)), $deployedUrl, $status, $deploymentId]
[$status, self::jsonEncode(self::redactPayload($result)), $effectiveDeployedUrl, $status, $deploymentId]
);
$this->execute(
"UPDATE release_versions SET status = ?, deployed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE deployed_at END WHERE id = ?",
'ssi',
[$status === 'deployed' ? 'deployed' : 'deploying', $status, $versionId]
"UPDATE release_versions
SET status = ?,
deployed_url = COALESCE(?, deployed_url),
deployed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE deployed_at END
WHERE id = ?",
'sssi',
[$status === 'deployed' ? 'deployed' : 'deploying', $effectiveDeployedUrl, $status, $versionId]
);
$this->audit((int)$channel['id'], $deploymentId, 'deployment_started', $actorUserId, 'info', [
'app' => $app,
@@ -2166,6 +2263,86 @@ class release_manager
return $default;
}
private function runtimeChannelsForContext(array $context, ?array $resolvedChannel = null): array
{
$channelsByKey = [];
$this->addRuntimeChannel($channelsByKey, $this->defaultChannel());
foreach ($this->assignmentCandidates($context) as [$subjectType, $subjectId]) {
$rows = $this->selectRows(
"SELECT c.*
FROM release_assignments a
INNER JOIN release_channels c ON c.id = a.channel_id
WHERE a.deleted_at IS NULL
AND c.deleted_at IS NULL
AND c.enabled = 1
AND a.subject_type = ?
AND a.subject_id = ?
AND (a.expires_at IS NULL OR a.expires_at > NOW())
ORDER BY a.created_at DESC, a.id DESC",
'ss',
[$subjectType, $subjectId]
);
foreach ($rows as $row) {
$this->addRuntimeChannel($channelsByKey, $row);
}
}
if ($resolvedChannel !== null) {
$this->addRuntimeChannel($channelsByKey, $resolvedChannel);
}
return array_values($channelsByKey);
}
private function assignmentCandidates(array $context): array
{
$candidates = [];
if (!empty($context['principal_type']) && !empty($context['principal_id'])) {
$candidates[] = [(string)$context['principal_type'], (string)$context['principal_id']];
}
if (!empty($context['customer_number'])) {
$candidates[] = ['customer', (string)(int)$context['customer_number']];
}
return $candidates;
}
private function addRuntimeChannel(array &$channelsByKey, array $channel): void
{
$id = (int)($channel['id'] ?? 0);
$slug = self::safeSlug((string)($channel['slug'] ?? ''));
$key = $id > 0 ? 'id:' . $id : ($slug !== '' ? 'slug:' . $slug : '');
if ($key === '' || isset($channelsByKey[$key])) {
return;
}
$channelsByKey[$key] = $channel;
}
private function chooseRuntimeChannel(array $resolvedChannel, array $availableChannels, string $requestedSlug): array
{
if ($requestedSlug === '') {
return $resolvedChannel;
}
foreach ($availableChannels as $channel) {
if (self::safeSlug((string)($channel['slug'] ?? '')) === $requestedSlug) {
return $channel;
}
}
return $resolvedChannel;
}
private function requestedRuntimeChannelSlug(array $input = []): string
{
$value = $input['release_channel']
?? $input['channel_slug']
?? ($_GET['release_channel'] ?? $_GET['channel_slug'] ?? '');
return self::safeSlug((string)$value);
}
private function rolloutChannelForContext(array $context): ?array
{
$seed = (string)($context['principal_id'] ?? $context['customer_number'] ?? '');
@@ -2301,32 +2478,107 @@ class release_manager
$serviceSet = null;
}
}
$bundle = null;
if (!empty($current['bundle_id'])) {
try {
$bundle = $this->publicBundle($this->getBundle((int)$current['bundle_id']), false);
} catch (Throwable) {
$bundle = null;
}
}
return [
'frontend' => $frontend,
'api' => $api,
'service_set' => $serviceSet,
'bundle_id' => isset($current['bundle_id']) ? (int)$current['bundle_id'] : null,
'bundle' => $bundle,
];
}
private function channelAvailability(array $channel): array
{
$channelId = (int)($channel['id'] ?? 0);
$isDefault = ((int)($channel['default_channel'] ?? 0) === 1) || (string)($channel['slug'] ?? '') === 'stable';
if ($isDefault || $channelId <= 0) {
return [
'configured' => true,
'missing' => [],
'status' => 'ready',
];
}
$versions = $this->currentVersionsForChannel($channelId);
$urls = $this->releaseRuntimeUrls($channel, $versions);
$missing = [];
if (trim((string)($channel['frontend_base_url'] ?? '')) === '') {
if (empty($versions['bundle_id'])) {
$missing[] = 'release_bundle';
}
if (empty($versions['frontend'])) {
$missing[] = 'frontend_version';
} elseif (empty($urls['frontend_base_url'])) {
$missing[] = 'frontend_base_url';
}
if (trim((string)($channel['api_base_url'] ?? '')) === '') {
if (empty($versions['api'])) {
$missing[] = 'api_version';
} elseif (empty($urls['api_base_url'])) {
$missing[] = 'api_base_url';
}
return [
'configured' => count($missing) === 0,
'missing' => $missing,
'bundle_id' => $versions['bundle_id'] ?? null,
'frontend_base_url' => $urls['frontend_base_url'],
'api_base_url' => $urls['api_base_url'],
'status' => count($missing) === 0 ? 'ready' : 'unconfigured',
];
}
private function releaseRuntimeUrls(array $channel, array $versions): array
{
$frontend = is_array($versions['frontend'] ?? null) ? $versions['frontend'] : [];
$api = is_array($versions['api'] ?? null) ? $versions['api'] : [];
return [
'frontend_base_url' => $this->normalizeReleasePublicBaseUrl(
$frontend['deployed_url'] ?? $channel['frontend_base_url'] ?? null,
'frontend'
),
'api_base_url' => $this->normalizeReleasePublicBaseUrl(
$api['deployed_url'] ?? $channel['api_base_url'] ?? null,
'api'
),
];
}
private function normalizeReleasePublicBaseUrl(mixed $value, string $app = ''): ?string
{
$raw = trim((string)$value);
if ($raw === '') {
return null;
}
$raw = preg_replace('#/(health|ping)$#i', '', rtrim($raw, '/')) ?: $raw;
if (preg_match('#^https?://#i', $raw) !== 1) {
$raw = 'https://' . ltrim($raw, '/');
}
$parts = parse_url($raw);
if (!is_array($parts) || empty($parts['host'])) {
return null;
}
$scheme = strtolower((string)($parts['scheme'] ?? 'https'));
if (!in_array($scheme, ['http', 'https'], true)) {
return null;
}
$path = isset($parts['path']) ? '/' . trim((string)$parts['path'], '/') : '';
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
return rtrim($scheme . '://' . strtolower((string)$parts['host']) . $port . $path, '/');
}
private function currentChannelVersionRow(int $channelId): ?array
{
return $this->selectOne(
@@ -2939,8 +3191,13 @@ class release_manager
private function releaseCoolifyPublicUrl(array $target, array $context): ?string
{
$raw = trim((string)($context['coolify_public_url'] ?? $context['coolify_domain'] ?? ''));
if ($raw === '' && !$this->toBool($context['coolify_enable_ssl'] ?? false)) {
$explicitPublicUrl = $this->normalizeReleasePublicBaseUrl($context['coolify_public_url'] ?? null, (string)($target['app'] ?? ''));
if ($explicitPublicUrl !== null) {
return $explicitPublicUrl;
}
$raw = trim((string)($context['coolify_domain'] ?? ''));
if ($raw === '') {
$raw = trim((string)($target['health_url'] ?? ''));
}
if ($raw === '') {
@@ -2953,11 +3210,19 @@ class release_manager
}
return 'https://' . $domain;
}
$raw = preg_replace('#/(health|ping)$#i', '', rtrim($raw, '/')) ?: $raw;
if (preg_match('#^https?://#i', $raw) !== 1) {
$raw = ($this->toBool($context['coolify_enable_ssl'] ?? false) ? 'https://' : 'http://') . $raw;
$raw = 'http://' . $raw;
}
return rtrim($raw, '/');
return $this->normalizeReleasePublicBaseUrl($raw, (string)($target['app'] ?? ''));
}
private function releaseTargetPublicBaseUrl(array $target): ?string
{
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
$app = (string)($target['app'] ?? '');
return $this->normalizeReleasePublicBaseUrl($context['coolify_public_url'] ?? null, $app)
?? $this->normalizeReleasePublicBaseUrl($context['coolify_domain'] ?? null, $app)
?? $this->normalizeReleasePublicBaseUrl($target['health_url'] ?? null, $app);
}
private function timelineSessionContext(array $context): array
@@ -3224,13 +3489,24 @@ class release_manager
$latestCommitSha = trim((string)($branchRow['commit']['sha'] ?? ''));
$commitSha = $latestCommitSha;
$commitUrl = (string)($branchRow['commit']['url'] ?? '');
$latestCommit = [];
if ($latestCommitSha !== '') {
$latestCommitRow = $this->githubRequest(
'GET',
'/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($latestCommitSha)
);
$latestCommit = is_array($latestCommitRow) ? $this->publicGithubCommit($latestCommitRow) : [];
$commitUrl = (string)($latestCommit['html_url'] ?? $commitUrl);
}
$commit = $latestCommit;
if ($commitMode === 'specific') {
$commitRow = $this->githubRequest(
'GET',
'/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($rawCommitSha)
);
$commitSha = trim((string)($commitRow['sha'] ?? $rawCommitSha));
$commitUrl = (string)($commitRow['html_url'] ?? $commitUrl);
$commit = is_array($commitRow) ? $this->publicGithubCommit($commitRow) : [];
$commitSha = trim((string)($commit['sha'] ?? (is_array($commitRow) ? ($commitRow['sha'] ?? null) : null) ?? $rawCommitSha));
$commitUrl = (string)($commit['html_url'] ?? (is_array($commitRow) ? ($commitRow['html_url'] ?? null) : null) ?? $commitUrl);
}
return [
@@ -3248,6 +3524,9 @@ class release_manager
'commit_mode' => $commitMode,
'commit_sha' => $commitSha !== '' ? $commitSha : null,
'latest_commit_sha' => $latestCommitSha !== '' ? $latestCommitSha : null,
'commit' => $commit !== [] ? $commit : null,
'latest_commit' => $latestCommit !== [] ? $latestCommit : null,
'commit_authored_at' => $commit['authored_at'] ?? null,
'commit_url' => $commitUrl !== '' ? $commitUrl : null,
];
} catch (Throwable $throwable) {
@@ -4247,7 +4526,7 @@ class release_manager
'commit_mode' => $commitMode,
'commit_sha' => $commitSha,
'version_label' => sprintf('%s-%s', $versionLabel, $app === 'api' ? 'php' : 'frontend'),
'deployed_url' => $target['health_url'] ?? null,
'deployed_url' => is_array($target) ? $this->releaseTargetPublicBaseUrl($target) : null,
'github_access' => $githubAccess,
];
}
@@ -4381,25 +4660,237 @@ class release_manager
];
}
private function publicRuntimeChannel(array $channel): array
{
$public = $this->publicChannel($channel);
unset($public['frontend_base_url'], $public['api_base_url']);
return $public;
}
private function publicRuntimeChannelOptions(array $channels): array
{
return array_map(function (array $channel): array {
return [
'channel' => $this->publicRuntimeChannel($channel),
'versions' => $this->currentVersionsForChannel((int)($channel['id'] ?? 0)),
'availability' => $this->channelAvailability($channel),
];
}, $channels);
}
public static function publicAssignmentSubjectSuggestion(array $candidate): ?array
{
$subjectType = strtolower(trim((string)($candidate['subject_type'] ?? '')));
if (!in_array($subjectType, self::SUBJECT_TYPES, true)) {
return null;
}
$subjectId = self::safeIdentifier((string)($candidate['subject_id'] ?? ''), 64);
if ($subjectId === '') {
return null;
}
$title = self::safeDisplayText($candidate['title'] ?? '', 120);
if ($title === '') {
$title = $subjectType . ':' . $subjectId;
}
$description = self::safeDisplayText($candidate['description'] ?? '', 180);
$icon = self::safeIconClass($candidate['icon'] ?? self::assignmentSubjectIcon($subjectType));
$source = self::safeIdentifier((string)($candidate['source'] ?? $subjectType), 32) ?: $subjectType;
return [
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'label' => $description !== '' ? $title . ' - ' . $description : $title,
'title' => $title,
'description' => $description,
'icon' => $icon,
'source' => $source,
];
}
private function searchAssignmentUsers(string $query, int $limit): array
{
$like = '%' . $query . '%';
$rows = $this->selectRows(
"SELECT id, customer_number, display_name, email, phone_country_code, phone
FROM users
WHERE CAST(id AS CHAR) LIKE ?
OR CAST(customer_number AS CHAR) LIKE ?
OR display_name LIKE ?
OR email LIKE ?
ORDER BY id DESC
LIMIT ?",
'ssssi',
[$like, $like, $like, $like, $limit]
);
return array_map(function (array $row): array {
$id = (string)($row['id'] ?? '');
$customerNumber = trim((string)($row['customer_number'] ?? ''));
$displayName = self::safeDisplayText($row['display_name'] ?? '', 80);
$email = self::safeDisplayText($row['email'] ?? '', 80);
$parts = array_filter([
$customerNumber !== '' ? 'Customer #' . $customerNumber : '',
$email,
$this->phoneLabel($row),
]);
return [
'subject_type' => 'user',
'subject_id' => $id,
'title' => $displayName !== '' ? $displayName : 'User #' . $id,
'description' => implode(' / ', $parts),
'icon' => self::assignmentSubjectIcon('user'),
'source' => 'users',
];
}, $rows);
}
private function searchAssignmentSubusers(string $query, int $limit): array
{
$like = '%' . $query . '%';
$rows = $this->selectRows(
"SELECT id, username, name, email, phone_country_code, phone
FROM subusers
WHERE CAST(id AS CHAR) LIKE ?
OR username LIKE ?
OR name LIKE ?
OR email LIKE ?
OR CAST(phone AS CHAR) LIKE ?
ORDER BY id DESC
LIMIT ?",
'sssssi',
[$like, $like, $like, $like, $like, $limit]
);
return array_map(function (array $row): array {
$id = (string)($row['id'] ?? '');
$name = self::safeDisplayText($row['name'] ?? '', 80);
$username = self::safeDisplayText($row['username'] ?? '', 80);
$email = self::safeDisplayText($row['email'] ?? '', 80);
$parts = array_filter([
$username !== '' ? '@' . ltrim($username, '@') : '',
$email,
$this->phoneLabel($row),
]);
return [
'subject_type' => 'subuser',
'subject_id' => $id,
'title' => $name !== '' ? $name : 'Subuser #' . $id,
'description' => implode(' / ', $parts),
'icon' => self::assignmentSubjectIcon('subuser'),
'source' => 'subusers',
];
}, $rows);
}
private function searchAssignmentCustomers(string $query, int $limit): array
{
try {
$result = (new economicCustomers())->listCustomers(1, $limit, $query, null);
} catch (Throwable) {
return [];
}
$customers = is_array($result->collection ?? null) ? $result->collection : [];
return array_map(static function (object $customer): array {
$customerNumber = (string)($customer->customerNumber ?? $customer->customer_number ?? '');
$name = self::safeDisplayText($customer->name ?? $customer->customer_name ?? '', 100);
$email = self::safeDisplayText($customer->email ?? '', 80);
$city = self::safeDisplayText($customer->city ?? '', 80);
$parts = array_filter([
$customerNumber !== '' ? 'Customer #' . $customerNumber : '',
$email,
$city,
]);
return [
'subject_type' => 'customer',
'subject_id' => $customerNumber,
'title' => $name !== '' ? $name : 'Customer #' . $customerNumber,
'description' => implode(' / ', $parts),
'icon' => self::assignmentSubjectIcon('customer'),
'source' => 'customers',
];
}, $customers);
}
private static function normalizeAssignmentSubjectSearch(mixed $value): string
{
$query = self::safeDisplayText($value, 80);
return trim($query);
}
private static function normalizeAssignmentSubjectLimit(mixed $value): int
{
$limit = (int)$value;
if ($limit <= 0) {
return 5;
}
return min(10, max(1, $limit));
}
private static function safeDisplayText(mixed $value, int $maxLength): string
{
$text = trim(strip_tags((string)$value));
$text = preg_replace('/\s+/', ' ', $text) ?? '';
return substr($text, 0, max(1, $maxLength));
}
private static function safeIconClass(mixed $value): string
{
$icon = trim((string)$value);
if (!preg_match('/^[a-z0-9 _-]+$/i', $icon)) {
return 'fas fa-tag';
}
return $icon;
}
private static function assignmentSubjectIcon(string $subjectType): string
{
return match ($subjectType) {
'customer' => 'fas fa-building',
'subuser' => 'fas fa-id-badge',
default => 'fas fa-user',
};
}
private function phoneLabel(array $row): string
{
$countryCode = trim((string)($row['phone_country_code'] ?? ''));
$phone = trim((string)($row['phone'] ?? ''));
if ($phone === '') {
return '';
}
return $countryCode !== '' ? '+' . $countryCode . ' ' . $phone : $phone;
}
private function publicVersion(?array $version): ?array
{
if (!$version || empty($version['id'])) {
return null;
}
$metadata = self::jsonDecode($version['metadata_json'] ?? null);
$commit = $this->versionGithubCommit(['metadata' => $metadata]);
return [
'id' => (int)$version['id'],
'app' => (string)$version['app'],
'repository' => $version['repository'] ?? null,
'branch' => $version['branch'] ?? null,
'commit_sha' => $version['commit_sha'] ?? null,
'commit' => $commit,
'commit_authored_at' => $commit['authored_at'] ?? null,
'tag' => $version['tag'] ?? null,
'version_label' => $version['version_label'] ?? null,
'build_url' => $version['build_url'] ?? null,
'artifact_url' => $version['artifact_url'] ?? null,
'deployed_url' => $version['deployed_url'] ?? null,
'status' => (string)($version['status'] ?? 'unknown'),
'metadata' => self::jsonDecode($version['metadata_json'] ?? null),
'metadata' => $metadata,
'created_at' => $version['created_at'] ?? null,
'deployed_at' => $version['deployed_at'] ?? null,
];
@@ -4493,6 +4984,10 @@ class release_manager
$apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
$frontendDeploymentId = $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null);
$apiDeploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null);
$frontendVersion = $frontendVersionId !== null ? $this->publicVersion($this->getVersion($frontendVersionId)) : null;
$apiVersion = $apiVersionId !== null ? $this->publicVersion($this->getVersion($apiVersionId)) : null;
$frontendCommit = $this->versionGithubCommit($frontendVersion);
$apiCommit = $this->versionGithubCommit($apiVersion);
$active = $bundleId > 0 && $this->bundleIsActive($bundleId);
$status = (string)($bundle['status'] ?? 'draft');
if ($status === 'promoted' && !$active) {
@@ -4518,7 +5013,9 @@ class release_manager
'repository' => $bundle['frontend_repository'] ?? null,
'branch' => $bundle['frontend_branch'] ?? null,
'commit_sha' => $bundle['frontend_commit_sha'] ?? null,
'version' => $frontendVersionId !== null ? $this->publicVersion($this->getVersion($frontendVersionId)) : null,
'commit' => $frontendCommit,
'commit_authored_at' => $frontendCommit['authored_at'] ?? null,
'version' => $frontendVersion,
'deployment' => $this->nullableDeployment($frontendDeploymentId),
],
'api' => [
@@ -4527,7 +5024,9 @@ class release_manager
'repository' => $bundle['api_repository'] ?? null,
'branch' => $bundle['api_branch'] ?? null,
'commit_sha' => $bundle['api_commit_sha'] ?? null,
'version' => $apiVersionId !== null ? $this->publicVersion($this->getVersion($apiVersionId)) : null,
'commit' => $apiCommit,
'commit_authored_at' => $apiCommit['authored_at'] ?? null,
'version' => $apiVersion,
'deployment' => $this->nullableDeployment($apiDeploymentId),
],
],
@@ -4541,6 +5040,20 @@ class release_manager
];
}
private function versionGithubCommit(?array $version): ?array
{
$metadata = is_array($version['metadata'] ?? null) ? $version['metadata'] : [];
$access = is_array($metadata['github_access'] ?? null) ? $metadata['github_access'] : [];
foreach (['commit', 'latest_commit'] as $key) {
$commit = is_array($access[$key] ?? null) ? $access[$key] : [];
if (trim((string)($commit['sha'] ?? '')) !== '') {
return $commit;
}
}
return null;
}
private function serviceSetBundles(int $serviceSetId): array
{
if ($serviceSetId <= 0 || !release_manager_schema_bootstrap::tablesExist()) {
@@ -20,7 +20,7 @@ class releaseManagerRoute
$this->get('/release/runtime', function () {
global $response;
$response->success((new release_manager())->runtimeForCurrentPrincipal());
$response->success((new release_manager())->runtimeForCurrentPrincipal($this->getParametersAsArray()));
});
$this->post('/release/timeline/events', function () {
@@ -161,6 +161,18 @@ class releaseManagerRoute
'superuser_release_manager_rollback' => 'Rollback an active release channel',
]);
$this->post('/superuser/releases/channels/{id}/bundle', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->setChannelBundle($this->routeId(), $this->requestPayload(), $this->actorUserId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_release_manager_deploy' => 'Set the active release bundle for a channel',
]);
$this->get('/superuser/releases/assignments', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
@@ -169,6 +181,14 @@ class releaseManagerRoute
'superuser_release_manager_view' => 'View release channel assignments',
]);
$this->get('/superuser/releases/assignment-subjects', function () {
global $response;
$this->requirePermission('superuser_release_manager_manage');
$response->success((new release_manager())->searchAssignmentSubjects($this->getParametersAsArray()));
}, [
'superuser_release_manager_manage' => 'Search users, subusers, and customers for Release Manager assignments',
]);
$this->post('/superuser/releases/assignments', function () {
global $response;
$this->requirePermission('superuser_release_manager_manage');
@@ -46,6 +46,32 @@ it('normalizes GitHub repository identifiers for private repository access check
expect(release_manager::normalizeGithubRepositoryName('not a repository'))->toBe('');
});
it('keeps GitHub commit timestamps in public release manager commit payloads', function (): void {
$manager = new release_manager();
$method = new ReflectionMethod(release_manager::class, 'publicGithubCommit');
$method->setAccessible(true);
$commit = $method->invoke($manager, [
'sha' => 'feedface00000000000000000000000000000000',
'html_url' => 'https://github.com/truckwash/backend-php/commit/feedface',
'commit' => [
'message' => "Deploy release bundle\n\nBody is intentionally omitted from option labels.",
'author' => [
'name' => 'Release Bot',
'date' => '2026-05-19T08:10:00Z',
],
],
]);
expect($commit)->toMatchArray([
'sha' => 'feedface00000000000000000000000000000000',
'short_sha' => 'feedface0000',
'message' => 'Deploy release bundle',
'author_name' => 'Release Bot',
'authored_at' => '2026-05-19T08:10:00Z',
]);
});
it('summarizes failed deployments and blocks promotion until a deployment succeeds', function (): void {
$summary = release_manager::deploymentFailureSummary(
new RuntimeException('Coolify API request failed: HTTP 404'),
@@ -381,6 +407,8 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($route)->toContain('/superuser/releases/github/commits');
expect($route)->toContain('/superuser/releases/github/test');
expect($route)->toContain('/superuser/releases/channels');
expect($route)->toContain('/superuser/releases/channels/{id}/bundle');
expect($route)->toContain('/superuser/releases/assignment-subjects');
expect($route)->toContain('/superuser/releases/assignments');
expect($route)->toContain('/superuser/releases/service-sets');
expect($route)->toContain("\$this->delete('/superuser/releases/service-sets/{id}'");
@@ -424,6 +452,12 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain('createBundle');
expect($manager)->toContain('deployBundle');
expect($manager)->toContain('promoteBundle');
expect($manager)->toContain('setChannelBundle');
expect($manager)->toContain('searchAssignmentSubjects');
expect($manager)->toContain('publicAssignmentSubjectSuggestion');
expect($manager)->toContain('available_channels');
expect($manager)->toContain('chooseRuntimeChannel');
expect($manager)->toContain('requestedRuntimeChannelSlug');
expect($manager)->toContain("status = 'superseded'");
expect($manager)->toContain('serviceSetIsActive');
expect($manager)->toContain('bundleIsActive');
@@ -469,6 +503,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain('load_balancer_domains');
expect($manager)->toContain('appendDomainSuggestion');
expect($manager)->toContain('Coolify SSL requires a DNS domain routed to the load balancer.');
expect($manager)->toContain('releaseRuntimeUrls');
expect($manager)->toContain('coolify_services');
expect($manager)->toContain('coolify_enable_ssl');
expect($manager)->toContain('createService');
@@ -484,3 +519,145 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($index)->toContain('X-Release-Trace');
expect($manager)->not->toContain('X-Release-Channel');
});
it('requires non-default release channel runtime URLs and preserves load balancer paths', function (): void {
$manager = new release_manager();
$availability = new ReflectionMethod(release_manager::class, 'channelAvailability');
$availability->setAccessible(true);
$runtimeUrls = new ReflectionMethod(release_manager::class, 'releaseRuntimeUrls');
$runtimeUrls->setAccessible(true);
$publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl');
$publicUrl->setAccessible(true);
expect($availability->invoke($manager, [
'id' => 1,
'slug' => 'stable',
'default_channel' => 1,
'frontend_base_url' => null,
'api_base_url' => null,
]))->toMatchArray([
'configured' => true,
'missing' => [],
'status' => 'ready',
]);
expect($runtimeUrls->invoke($manager, [
'id' => 2,
'slug' => 'canary',
'default_channel' => 0,
'frontend_base_url' => null,
'api_base_url' => null,
], [
'frontend' => ['deployed_url' => 'https://api-v2.truckwash.io/canary/frontend/health'],
'api' => ['deployed_url' => 'https://api-v2.truckwash.io/canary/api/ping'],
]))->toBe([
'frontend_base_url' => 'https://api-v2.truckwash.io/canary/frontend',
'api_base_url' => 'https://api-v2.truckwash.io/canary/api',
]);
expect($publicUrl->invoke($manager, ['app' => 'frontend'], [
'coolify_public_url' => 'https://api-v2.truckwash.io/canary/frontend',
'coolify_enable_ssl' => true,
]))->toBe('https://api-v2.truckwash.io/canary/frontend');
$source = file(app_path('classes/release_manager.php'));
$methodSource = implode('', array_slice(
$source,
$availability->getStartLine() - 1,
$availability->getEndLine() - $availability->getStartLine() + 1
));
expect($methodSource)->toContain('frontend_base_url');
expect($methodSource)->toContain('api_base_url');
expect($methodSource)->toContain('release_bundle');
expect($methodSource)->toContain('frontend_version');
expect($methodSource)->toContain('api_version');
});
it('exposes release version git commit metadata for runtime channel cards', function (): void {
$manager = new release_manager();
$publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion');
$publicVersion->setAccessible(true);
$version = $publicVersion->invoke($manager, [
'id' => 12,
'app' => 'frontend',
'repository' => 'truckwash/front-end-vue',
'branch' => 'release/canary',
'commit_sha' => 'c0ffee0000001111222233334444555566667777',
'tag' => null,
'version_label' => 'frontend-canary',
'build_url' => null,
'artifact_url' => null,
'deployed_url' => null,
'status' => 'active',
'metadata_json' => json_encode([
'github_access' => [
'commit' => [
'sha' => 'c0ffee0000001111222233334444555566667777',
'authored_at' => '2026-05-19T10:15:00Z',
],
],
]),
'created_at' => '2026-05-19 10:10:00',
'deployed_at' => '2026-05-19 10:20:00',
]);
expect($version['commit_sha'])->toBe('c0ffee0000001111222233334444555566667777');
expect($version['commit']['sha'])->toBe('c0ffee0000001111222233334444555566667777');
expect($version['commit_authored_at'])->toBe('2026-05-19T10:15:00Z');
expect($version['deployed_at'])->toBe('2026-05-19 10:20:00');
});
it('chooses a requested runtime channel only when it is available to the principal', function (): void {
$manager = new release_manager();
$choose = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
$choose->setAccessible(true);
$stable = [
'id' => 1,
'slug' => 'stable',
'name' => 'Stable',
'default_channel' => 1,
];
$canary = [
'id' => 2,
'slug' => 'canary',
'name' => 'Canary',
'default_channel' => 0,
];
expect($choose->invoke($manager, $stable, [$stable, $canary], 'canary'))->toBe($canary);
expect($choose->invoke($manager, $stable, [$stable, $canary], 'unknown'))->toBe($stable);
expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary);
});
it('normalizes release assignment subject suggestions without leaking private fields', function (): void {
$suggestion = release_manager::publicAssignmentSubjectSuggestion([
'subject_type' => 'USER',
'subject_id' => 42,
'title' => ' Dispatcher ',
'description' => 'Customer #424242 / dispatcher@example.test',
'icon' => 'fas fa-user',
'source' => 'users',
'password' => 'secret',
'two_factor_secret' => 'private',
]);
expect($suggestion)->toBe([
'subject_type' => 'user',
'subject_id' => '42',
'label' => 'Dispatcher - Customer #424242 / dispatcher@example.test',
'title' => 'Dispatcher',
'description' => 'Customer #424242 / dispatcher@example.test',
'icon' => 'fas fa-user',
'source' => 'users',
]);
expect(array_keys($suggestion))->not->toContain('password');
expect(array_keys($suggestion))->not->toContain('two_factor_secret');
expect(release_manager::publicAssignmentSubjectSuggestion([
'subject_type' => 'invalid',
'subject_id' => 42,
'title' => 'Invalid',
]))->toBeNull();
});