Add department_selfserve_path_confirmations table and enhance PingApiTest

Introduce a new database table `department_selfserve_path_confirmations` to store path confirmations related to department configurations. Update `PingApiTest` to verify additional keys, ensuring `backend_version` and `api_commit_sha` are checked in the response.
This commit is contained in:
Jeppe Bundgaard
2026-05-27 17:35:16 +02:00
parent d52ceb8513
commit b7aeb11801
15 changed files with 2104 additions and 46 deletions
+24
View File
@@ -253,3 +253,27 @@ jobs:
- name: Tear down local stack
if: always()
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
release-manager-gate:
name: Release Manager gate
runs-on: [self-hosted, Linux, X64, default]
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
steps:
- name: Record Release Manager API gate
run: |
set -euo pipefail
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
curl --fail --show-error --silent \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.ref_name }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
File diff suppressed because one or more lines are too long
+645 -33
View File
@@ -132,6 +132,7 @@ class release_manager
];
private bool $schemaEnsured = false;
private array $inProcessPassedReleaseGates = [];
public static function initializeRequestContext(): array
{
@@ -813,6 +814,22 @@ class release_manager
]);
$statuses[] = 'passed';
if ($this->releaseGateAutoSyncRequested($gateInput)) {
foreach ($this->releaseGateAutoSyncValidationSteps($gateInput, $channel) as $autoSyncStep) {
$this->recordOperationStep(
$operationId,
(string)$autoSyncStep['step_key'],
(string)$autoSyncStep['label'],
(string)$autoSyncStep['status'],
$autoSyncStep['message'] ?? null,
$autoSyncStep['diagnostic'] ?? null,
$autoSyncStep['solution_hint'] ?? null,
is_array($autoSyncStep['context'] ?? null) ? $autoSyncStep['context'] : []
);
$statuses[] = (string)$autoSyncStep['status'];
}
}
if (($gateInput['required_checks'] ?? []) !== []) {
$this->recordOperationStep(
$operationId,
@@ -905,6 +922,45 @@ class release_manager
$finalStatus = in_array('failed', $statuses, true)
? 'failed'
: (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed');
if ($finalStatus === 'passed' && $this->releaseGateAutoSyncRequested($gateInput)) {
try {
$this->inProcessPassedReleaseGates[$operationId] = [
'channel_id' => $channel !== null ? (int)$channel['id'] : null,
'release_gate' => $gateInput,
];
$autoSyncResult = $this->processReleaseGateAutoSync($gateInput, $channel, $operationId, $actorUserId);
$autoSyncStepStatus = (string)($autoSyncResult['step_status'] ?? 'passed');
$this->recordOperationStep(
$operationId,
'auto_sync',
'Automatic container update',
$autoSyncStepStatus,
(string)($autoSyncResult['message'] ?? 'Automatic container update completed.'),
$autoSyncResult['diagnostic'] ?? null,
$autoSyncResult['solution_hint'] ?? null,
$autoSyncResult
);
$statuses[] = $autoSyncStepStatus;
} catch (Throwable $throwable) {
$this->recordOperationStep(
$operationId,
'auto_sync',
'Automatic container update',
'failed',
'Automatic container update failed after the release gate passed.',
$throwable->getMessage(),
'Open the channel sync operation or Release Manager target diagnostics, fix the failure, then rerun the gate.',
$gateInput
);
$statuses[] = 'failed';
}
$finalStatus = in_array('failed', $statuses, true)
? 'failed'
: (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed');
}
$this->completeOperationRun(
$operationId,
$finalStatus,
@@ -945,11 +1001,27 @@ class release_manager
$apiBaseUrl = $this->normalizeReleaseGateUrl((string)($input['api_base_url'] ?? 'https://api-v2.truckwash.io'));
$requiredChecks = $this->normalizeReleaseGateChecks($input, $environmentUrl);
$routeSlug = self::routeSlugForChannel($channelSlug ?: 'stable') ?: 'master';
$app = '';
if (trim((string)($input['app'] ?? '')) !== '') {
try {
$app = $this->normalizeApp((string)$input['app']);
} catch (Throwable) {
$app = '';
}
}
$repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? $input['repo'] ?? ''));
$branch = trim((string)($input['branch'] ?? ''));
$workflowUrl = $this->normalizeReleaseGateUrl((string)($input['workflow_url'] ?? $input['build_url'] ?? ''));
return [
'environment_url' => $environmentUrl,
'channel_slug' => $channelSlug,
'route_slug' => $routeSlug,
'app' => $app,
'repository' => $repository,
'branch' => $branch,
'auto_sync' => $this->toBool($input['auto_sync'] ?? false),
'workflow_url' => $workflowUrl,
'expected_commit' => self::safeIdentifier((string)($input['expected_commit'] ?? $input['commit_sha'] ?? ''), 128),
'build_id' => substr(trim((string)($input['build_id'] ?? '')), 0, 128),
'wait_timeout_seconds' => max(0, min(300, (int)($input['wait_timeout_seconds'] ?? 300))),
@@ -984,6 +1056,70 @@ class release_manager
return $normalized;
}
private function releaseGateAutoSyncRequested(array $gateInput): bool
{
return (bool)($gateInput['auto_sync'] ?? false);
}
private function releaseGateAutoSyncValidationSteps(array $gateInput, ?array $channel): array
{
$steps = [];
$context = [
'channel_slug' => $gateInput['channel_slug'] ?? null,
'app' => $gateInput['app'] ?? null,
'repository' => $gateInput['repository'] ?? null,
'branch' => $gateInput['branch'] ?? null,
'expected_commit' => $gateInput['expected_commit'] ?? null,
'workflow_url' => $gateInput['workflow_url'] ?? null,
];
if ($channel === null) {
$steps[] = [
'step_key' => 'auto_sync_channel',
'label' => 'Automatic update channel',
'status' => 'failed',
'message' => 'Automatic container updates require a release channel.',
'diagnostic' => 'The gate payload did not resolve to a configured release channel.',
'solution_hint' => 'Pass channel_slug from CI, for example stable for master.',
'context' => $context,
];
}
if (trim((string)($gateInput['app'] ?? '')) === '') {
$steps[] = [
'step_key' => 'auto_sync_app',
'label' => 'Automatic update app',
'status' => 'failed',
'message' => 'Automatic container updates require an app.',
'diagnostic' => 'The gate payload must identify frontend or api so Release Manager updates exactly one container.',
'solution_hint' => 'Pass app=frontend from the frontend workflow or app=api from the backend workflow.',
'context' => $context,
];
}
if (trim((string)($gateInput['expected_commit'] ?? '')) === '') {
$steps[] = [
'step_key' => 'auto_sync_commit',
'label' => 'Automatic update commit',
'status' => 'failed',
'message' => 'Automatic container updates require the CI-verified commit SHA.',
'diagnostic' => 'expected_commit was empty.',
'solution_hint' => 'Pass github.sha as expected_commit in the release gate payload.',
'context' => $context,
];
}
if ($steps === []) {
$steps[] = [
'step_key' => 'auto_sync_inputs',
'label' => 'Automatic update inputs',
'status' => 'passed',
'message' => 'Release gate payload includes app, channel, and exact commit metadata for automatic container updates.',
'context' => $context,
];
}
return $steps;
}
private function releaseGateStringArray(mixed $value): array
{
if (is_string($value)) {
@@ -1040,7 +1176,7 @@ class release_manager
return $steps;
}
private function assertReleaseGatePassedForPromotion(int $channelId, ?string $expectedCommit = null, ?string $buildId = null): void
private function assertReleaseGatePassedForPromotion(int $channelId, ?string $expectedCommit = null, ?string $buildId = null, ?string $app = null): void
{
if (!$this->releaseGateRequiredForPromotion()) {
return;
@@ -1048,6 +1184,24 @@ class release_manager
$expectedCommit = trim((string)$expectedCommit);
$buildId = trim((string)$buildId);
$app = trim((string)$app) !== '' ? $this->normalizeApp((string)$app) : '';
foreach ($this->inProcessPassedReleaseGates as $inProcessGate) {
if ((int)($inProcessGate['channel_id'] ?? 0) !== $channelId) {
continue;
}
$gate = is_array($inProcessGate['release_gate'] ?? null) ? $inProcessGate['release_gate'] : [];
if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) {
continue;
}
if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) {
continue;
}
if ($buildId !== '' && (string)($gate['build_id'] ?? '') !== $buildId) {
continue;
}
return;
}
$rows = $this->selectRows(
"SELECT id, context_json, completed_at
FROM release_operation_runs
@@ -1064,6 +1218,9 @@ class release_manager
foreach ($rows as $row) {
$context = json_decode((string)($row['context_json'] ?? ''), true);
$gate = is_array($context['release_gate'] ?? null) ? $context['release_gate'] : [];
if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) {
continue;
}
if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) {
continue;
}
@@ -1077,6 +1234,35 @@ class release_manager
throw new RuntimeException('A passing Release Manager gate is required before promotion. Run the dev upload, public live smoke, credentialed smoke, and api-v2 health checks, then retry promotion.');
}
private function releaseGateAppMatches(array $gate, string $app): bool
{
$app = $this->normalizeApp($app);
$gateApp = trim((string)($gate['app'] ?? ''));
if ($gateApp !== '') {
try {
return $this->normalizeApp($gateApp) === $app;
} catch (Throwable) {
return false;
}
}
$gateApps = $this->releaseGateStringArray($gate['apps'] ?? []);
if ($gateApps !== []) {
foreach ($gateApps as $value) {
try {
if ($this->normalizeApp($value) === $app) {
return true;
}
} catch (Throwable) {
}
}
return false;
}
// Gates recorded before app-specific payloads existed were frontend release gates.
return $app === 'frontend';
}
private function releaseGateRequiredForPromotion(): bool
{
$envValue = trim((string)(getenv('RELEASE_GATE_REQUIRED_FOR_PROMOTION') ?: ($_SERVER['RELEASE_GATE_REQUIRED_FOR_PROMOTION'] ?? '')));
@@ -1362,14 +1548,92 @@ class release_manager
private function releaseGateCommitMatches(string $actual, string $expected): bool
{
$expected = trim($expected);
$expected = strtolower(trim($expected));
if ($expected === '') {
return true;
}
$actual = trim($actual);
$actual = strtolower(trim($actual));
return $actual !== '' && ($actual === $expected || str_starts_with($actual, $expected));
}
private function verifyReleaseDeploymentReadiness(array $deployment, array $target, string $app, string $expectedCommit, array $options = []): array
{
$app = $this->normalizeApp($app);
$baseUrl = $this->normalizeReleasePublicBaseUrl($deployment['deployment_url'] ?? null, $app)
?? $this->releaseTargetPublicBaseUrl($target);
if ($baseUrl === null || $baseUrl === '') {
throw new RuntimeException(sprintf('%s deployment has no public URL for readiness verification.', strtoupper($app)));
}
$timeout = max(0, min(300, (int)($options['wait_timeout_seconds'] ?? 300)));
$pollInterval = max(1, min(60, (int)($options['poll_interval_seconds'] ?? 10)));
$deadline = time() + $timeout;
$attempts = 0;
$lastMessage = 'Readiness verification did not run.';
do {
$attempts++;
try {
if ($app === 'frontend') {
$context = $this->releaseStaticArtifactAttempt([
'environment_url' => $baseUrl,
'expected_commit' => $expectedCommit,
'build_id' => (string)($options['build_id'] ?? ''),
'shell_paths' => $this->releaseGateStringArray($options['shell_paths'] ?? ['/', '/guest/book/wash']),
]);
$context['app'] = $app;
$context['base_url'] = $baseUrl;
$context['attempts'] = $attempts;
return $context;
}
$json = $this->releaseGateFetchJson($baseUrl, 'ping');
$payload = $json['json'];
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
throw new RuntimeException('API ping returned success=false.');
}
$data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload;
$actualCommit = (string)(
$data['api_commit_sha']
?? $data['backend_commit_sha']
?? $data['commit_sha']
?? $data['backend_version']
?? ''
);
if (!$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) {
throw new RuntimeException(sprintf(
'API ping commit %s did not match expected commit %s.',
$actualCommit !== '' ? $actualCommit : '(missing)',
$expectedCommit
));
}
return [
'app' => $app,
'base_url' => $baseUrl,
'path' => 'ping',
'status' => $json['status'] ?? null,
'commit_sha' => $actualCommit,
'attempts' => $attempts,
];
} catch (Throwable $throwable) {
$lastMessage = $throwable->getMessage();
if (time() >= $deadline) {
break;
}
sleep($pollInterval);
}
} while (time() <= $deadline);
throw new RuntimeException(sprintf(
'%s container readiness did not match commit %s after %d attempts: %s',
strtoupper($app),
$expectedCommit,
$attempts,
$lastMessage
));
}
private function releaseGateRejectsHtml(string $assetUrl): bool
{
return preg_match('/\.(js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i', parse_url($assetUrl, PHP_URL_PATH) ?: '') === 1;
@@ -1401,8 +1665,11 @@ class release_manager
$requestedApp = $this->normalizeApp((string)$options['app']);
}
$apps = $requestedApp !== '' ? [$requestedApp] : self::APPS;
$branch = self::releaseBranchForChannel($channel);
$branch = trim((string)($options['branch'] ?? '')) ?: self::releaseBranchForChannel($channel);
$routeSlug = self::routeSlugForChannel((string)$channel['slug']);
$requestedCommitSha = self::normalizeCommitSha((string)($options['commit_sha'] ?? $options['commit'] ?? ''));
$commitMode = $requestedCommitSha !== '' ? 'specific' : 'latest';
$requireReadiness = $this->toBool($options['require_readiness'] ?? false);
$operationId = $this->createOperationRun('channel_sync', [
'subject_type' => 'channel',
@@ -1417,6 +1684,13 @@ class release_manager
'branch' => $branch,
'apps' => $apps,
'source' => $options['source'] ?? 'manual',
'repository' => $options['repository'] ?? null,
'commit_mode' => $commitMode,
'commit_sha' => $requestedCommitSha !== '' ? $requestedCommitSha : null,
'workflow_url' => $options['workflow_url'] ?? $options['build_url'] ?? null,
'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null,
'auto_sync_event' => $options['auto_sync_event'] ?? null,
'gate_operation_id' => $options['gate_operation_id'] ?? null,
],
]);
@@ -1512,13 +1786,18 @@ class release_manager
}
$repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app)));
$requestedRepository = self::normalizeGithubRepositoryName((string)($options['repository'] ?? ''));
if ($requestedRepository !== '') {
$repository = $requestedRepository;
}
if ($repository === '') {
$repository = self::defaultRepositoryForApp($app);
}
$access = $this->githubRepositoryAccess([
'repository' => $repository,
'branch' => $branch,
'commit_mode' => 'latest',
'commit_sha' => $requestedCommitSha,
'commit_mode' => $commitMode,
]);
if (!($access['ok'] ?? false)) {
$this->recordOperationStep(
@@ -1567,20 +1846,37 @@ class release_manager
'app' => $app,
'repository' => $repository,
'branch' => $branch,
'commit_mode' => 'latest',
'commit_mode' => $commitMode,
'commit_sha' => $commitSha,
'version_label' => $commitSha !== '' ? substr($commitSha, 0, 12) : date('Ymd-His'),
'build_url' => $options['build_url'] ?? null,
'metadata' => [
'release_operation_id' => $operationId,
'sync_source' => $options['source'] ?? 'manual',
'webhook_commit_sha' => $options['commit_sha'] ?? null,
'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null,
'gate_operation_id' => $options['gate_operation_id'] ?? null,
'workflow_url' => $options['workflow_url'] ?? null,
],
], $actorUserId);
$deployments[] = $deployment;
if (($deployment['status'] ?? '') === 'deployed' && !empty($deployment['id'])) {
if ($requireReadiness) {
$readiness = $this->verifyReleaseDeploymentReadiness($deployment, $target, $app, $commitSha, $options);
$this->recordOperationStep(
$operationId,
$app . '_readiness',
strtoupper($app) . ' container readiness',
'passed',
sprintf('%s container readiness matched commit %s.', strtoupper($app), substr($commitSha, 0, 12)),
null,
null,
$readiness
);
}
$promoted = $this->promoteDeployment((int)$deployment['id'], $actorUserId);
$deployment = $promoted['deployment'] ?? $deployment;
}
$deployments[] = $deployment;
$this->recordOperationStep(
$operationId,
$app . '_deploy',
@@ -1627,6 +1923,315 @@ class release_manager
return $operation;
}
private function processReleaseGateAutoSync(array $gateInput, ?array $channel, int $gateOperationId, ?int $actorUserId): array
{
if ($channel === null) {
throw new RuntimeException('Automatic container update requires a release channel.');
}
$channelId = (int)$channel['id'];
$app = $this->normalizeApp((string)($gateInput['app'] ?? ''));
$commitSha = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? ''));
if ($commitSha === '') {
throw new RuntimeException('Automatic container update requires a 7-40 character Git commit SHA.');
}
$branch = trim((string)($gateInput['branch'] ?? '')) ?: self::releaseBranchForChannel($channel);
$repository = self::normalizeGithubRepositoryName((string)($gateInput['repository'] ?? ''));
if ($repository === '') {
$repository = self::defaultRepositoryForApp($app);
}
$target = $this->deploymentTargetForChannelApp($channelId, $app);
if ($target === null) {
throw new RuntimeException(sprintf('No %s deployment target is configured for %s.', strtoupper($app), (string)$channel['slug']));
}
if (!$this->toBool($target['auto_deploy'] ?? false)) {
throw new RuntimeException(sprintf('%s automatic deployments are disabled for %s.', strtoupper($app), (string)$channel['slug']));
}
$targetRepository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? ''));
$targetBranch = trim((string)($target['branch'] ?? ''));
if ($targetRepository !== '' && $repository !== $targetRepository) {
throw new RuntimeException(sprintf('Gate repository %s does not match target repository %s.', $repository, $targetRepository));
}
if ($targetBranch !== '' && $branch !== $targetBranch) {
throw new RuntimeException(sprintf('Gate branch %s does not match target branch %s.', $branch, $targetBranch));
}
$event = $this->upsertReleaseAutoSyncEvent([
'channel_id' => $channelId,
'app' => $app,
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'status' => 'gate_passed',
'source' => 'release_gate',
'workflow_url' => $gateInput['workflow_url'] ?? null,
'gate_operation_id' => $gateOperationId,
'metadata' => [
'release_gate' => $gateInput,
],
]);
$eventId = (int)$event['id'];
if (!$this->acquireReleaseAutoSyncLock($eventId)) {
return [
'step_status' => 'passed',
'message' => 'Automatic container update is already being processed for this commit.',
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
];
}
try {
$event = $this->releaseAutoSyncEventById($eventId) ?? $event;
if (in_array((string)($event['status'] ?? ''), ['promoted', 'deployed'], true)) {
return [
'step_status' => 'passed',
'message' => 'Automatic container update was already completed for this commit.',
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
];
}
$current = $this->currentDeploymentForChannelApp($channelId, $app);
if ($current !== null && $this->releaseGateCommitMatches((string)($current['commit_sha'] ?? ''), $commitSha)) {
$event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [
'deployment_id' => (int)$current['id'],
'metadata' => ['already_current' => true],
]);
return [
'step_status' => 'passed',
'message' => sprintf('%s is already active at %s.', strtoupper($app), substr($commitSha, 0, 12)),
'deployment' => $this->publicDeployment($current),
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
];
}
$event = $this->updateReleaseAutoSyncEvent($eventId, 'syncing');
$operation = $this->syncChannel($channelId, $actorUserId, [
'app' => $app,
'source' => 'release_gate',
'repository' => $repository,
'branch' => $branch,
'commit_mode' => 'specific',
'commit_sha' => $commitSha,
'build_url' => $gateInput['workflow_url'] ?? null,
'workflow_url' => $gateInput['workflow_url'] ?? null,
'gate_operation_id' => $gateOperationId,
'auto_sync_event_id' => $eventId,
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
'require_readiness' => true,
'wait_timeout_seconds' => $gateInput['wait_timeout_seconds'] ?? 300,
'poll_interval_seconds' => $gateInput['poll_interval_seconds'] ?? 10,
'build_id' => $gateInput['build_id'] ?? '',
'shell_paths' => $gateInput['shell_paths'] ?? [],
]);
if ((string)($operation['status'] ?? '') !== 'passed') {
throw new RuntimeException((string)($operation['summary'] ?? 'Automatic channel sync did not pass.'));
}
$deployment = $this->currentDeploymentForChannelApp($channelId, $app);
if ($deployment === null || !$this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) {
throw new RuntimeException(sprintf('%s was deployed but was not promoted as the active %s release.', strtoupper($app), (string)$channel['slug']));
}
$event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [
'sync_operation_id' => (int)($operation['id'] ?? 0) ?: null,
'deployment_id' => (int)$deployment['id'],
'metadata' => [
'sync_operation_id' => $operation['id'] ?? null,
'deployment_id' => $deployment['id'] ?? null,
],
]);
return [
'step_status' => 'passed',
'message' => sprintf('%s container was deployed and promoted at %s.', strtoupper($app), substr($commitSha, 0, 12)),
'sync_operation' => $operation,
'deployment' => $this->publicDeployment($deployment),
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
];
} catch (Throwable $throwable) {
$this->updateReleaseAutoSyncEvent($eventId, 'failed', [
'error_message' => $throwable->getMessage(),
]);
throw $throwable;
} finally {
$this->releaseReleaseAutoSyncLock($eventId);
}
}
private function upsertReleaseAutoSyncEvent(array $input): array
{
$channelId = (int)$input['channel_id'];
$app = $this->normalizeApp((string)$input['app']);
$repository = self::normalizeGithubRepositoryName((string)$input['repository']);
$branch = trim((string)$input['branch']);
$commitSha = self::normalizeCommitSha((string)$input['commit_sha']);
$status = self::safeIdentifier((string)($input['status'] ?? 'pending'), 32) ?: 'pending';
$source = self::safeIdentifier((string)($input['source'] ?? ''), 64) ?: null;
$workflowUrl = $this->nullableString($input['workflow_url'] ?? null, 512);
$gateOperationId = $this->nullablePositiveInt($input['gate_operation_id'] ?? null);
$metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : [];
if ($channelId <= 0 || $repository === '' || $branch === '' || $commitSha === '') {
throw new RuntimeException('Automatic sync event requires channel, app, repository, branch, and commit.');
}
$existing = $this->releaseAutoSyncEventFor($channelId, $app, $repository, $branch, $commitSha);
if ($existing === null) {
$this->execute(
"INSERT INTO release_auto_sync_events (
channel_id, app, repository, branch, commit_sha, status, source,
workflow_url, gate_operation_id, metadata_json, gate_passed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = 'gate_passed' THEN NOW() ELSE NULL END)",
'isssssssiss',
[
$channelId,
$app,
$repository,
$branch,
$commitSha,
$status,
$source,
$workflowUrl,
$gateOperationId,
self::jsonEncode(self::redactPayload($metadata)),
$status,
]
);
return $this->releaseAutoSyncEventById($this->insertId()) ?? [];
}
$existingStatus = (string)($existing['status'] ?? 'pending');
if ($status === 'pending' && !in_array($existingStatus, ['pending', 'failed'], true)) {
$status = $existingStatus;
}
if ($status === 'gate_passed' && in_array($existingStatus, ['syncing', 'promoted', 'deployed'], true)) {
$status = $existingStatus;
}
$this->execute(
"UPDATE release_auto_sync_events
SET status = ?,
source = COALESCE(?, source),
workflow_url = COALESCE(?, workflow_url),
gate_operation_id = COALESCE(NULLIF(?, 0), gate_operation_id),
error_message = NULL,
metadata_json = ?,
gate_passed_at = CASE WHEN ? = 'gate_passed' THEN COALESCE(gate_passed_at, NOW()) ELSE gate_passed_at END,
updated_at = NOW()
WHERE id = ?",
'sssissi',
[
$status,
$source,
$workflowUrl,
$gateOperationId ?? 0,
self::jsonEncode(self::redactPayload($metadata)),
$status,
(int)$existing['id'],
]
);
return $this->releaseAutoSyncEventById((int)$existing['id']) ?? [];
}
private function updateReleaseAutoSyncEvent(int $id, string $status, array $input = []): array
{
$status = self::safeIdentifier($status, 32) ?: 'pending';
$syncOperationId = $this->nullablePositiveInt($input['sync_operation_id'] ?? null);
$deploymentId = $this->nullablePositiveInt($input['deployment_id'] ?? null);
$errorMessage = isset($input['error_message']) ? substr((string)$input['error_message'], 0, 4096) : null;
$metadata = is_array($input['metadata'] ?? null) ? self::jsonEncode(self::redactPayload($input['metadata'])) : null;
$this->execute(
"UPDATE release_auto_sync_events
SET status = ?,
sync_operation_id = COALESCE(NULLIF(?, 0), sync_operation_id),
deployment_id = COALESCE(NULLIF(?, 0), deployment_id),
error_message = ?,
metadata_json = COALESCE(?, metadata_json),
synced_at = CASE WHEN ? IN ('deployed', 'promoted') THEN COALESCE(synced_at, NOW()) ELSE synced_at END,
promoted_at = CASE WHEN ? = 'promoted' THEN COALESCE(promoted_at, NOW()) ELSE promoted_at END,
failed_at = CASE WHEN ? = 'failed' THEN NOW() ELSE failed_at END,
updated_at = NOW()
WHERE id = ?",
'siisssssi',
[$status, $syncOperationId ?? 0, $deploymentId ?? 0, $errorMessage, $metadata, $status, $status, $status, $id]
);
return $this->releaseAutoSyncEventById($id) ?? [];
}
private function releaseAutoSyncEventFor(int $channelId, string $app, string $repository, string $branch, string $commitSha): ?array
{
return $this->selectOne(
"SELECT e.*, c.slug AS channel_slug, c.name AS channel_name
FROM release_auto_sync_events e
INNER JOIN release_channels c ON c.id = e.channel_id
WHERE e.channel_id = ? AND e.app = ? AND e.repository = ? AND e.branch = ? AND e.commit_sha = ?
LIMIT 1",
'issss',
[$channelId, $app, $repository, $branch, $commitSha]
);
}
private function releaseAutoSyncEventById(int $id): ?array
{
return $this->selectOne(
"SELECT e.*, c.slug AS channel_slug, c.name AS channel_name
FROM release_auto_sync_events e
INNER JOIN release_channels c ON c.id = e.channel_id
WHERE e.id = ?
LIMIT 1",
'i',
[$id]
);
}
private function publicReleaseAutoSyncEvent(array $event): array
{
return [
'id' => (int)($event['id'] ?? 0),
'channel_id' => (int)($event['channel_id'] ?? 0),
'channel_slug' => $event['channel_slug'] ?? null,
'channel_name' => $event['channel_name'] ?? null,
'app' => (string)($event['app'] ?? ''),
'repository' => (string)($event['repository'] ?? ''),
'branch' => (string)($event['branch'] ?? ''),
'commit_sha' => (string)($event['commit_sha'] ?? ''),
'status' => (string)($event['status'] ?? 'unknown'),
'source' => $event['source'] ?? null,
'workflow_url' => $event['workflow_url'] ?? null,
'gate_operation_id' => isset($event['gate_operation_id']) ? (int)$event['gate_operation_id'] : null,
'sync_operation_id' => isset($event['sync_operation_id']) ? (int)$event['sync_operation_id'] : null,
'deployment_id' => isset($event['deployment_id']) ? (int)$event['deployment_id'] : null,
'error_message' => $event['error_message'] ?? null,
'metadata' => self::jsonDecode($event['metadata_json'] ?? null),
'received_at' => $event['received_at'] ?? null,
'gate_passed_at' => $event['gate_passed_at'] ?? null,
'synced_at' => $event['synced_at'] ?? null,
'promoted_at' => $event['promoted_at'] ?? null,
'failed_at' => $event['failed_at'] ?? null,
];
}
private function acquireReleaseAutoSyncLock(int $eventId): bool
{
$lockName = 'release_auto_sync:' . $eventId;
$row = $this->selectOne('SELECT GET_LOCK(?, 0) AS acquired', 's', [$lockName]);
return (int)($row['acquired'] ?? 0) === 1;
}
private function releaseReleaseAutoSyncLock(int $eventId): void
{
try {
$this->selectOne('SELECT RELEASE_LOCK(?) AS released', 's', ['release_auto_sync:' . $eventId]);
} catch (Throwable) {
}
}
public function runIssueAction(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
@@ -4111,8 +4716,17 @@ class release_manager
$this->assertReleaseGatePassedForPromotion(
$channelId,
(string)($bundle['frontend_commit_sha'] ?? ''),
null
null,
'frontend'
);
if (trim((string)($bundle['api_commit_sha'] ?? '')) !== '') {
$this->assertReleaseGatePassedForPromotion(
$channelId,
(string)$bundle['api_commit_sha'],
null,
'api'
);
}
$frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
$apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
$deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null)
@@ -4423,8 +5037,9 @@ class release_manager
}
$this->assertReleaseGatePassedForPromotion(
$channelId,
(string)($deployment['app'] ?? '') === 'frontend' ? (string)($deployment['commit_sha'] ?? '') : '',
null
(string)($deployment['commit_sha'] ?? ''),
null,
(string)($deployment['app'] ?? '')
);
$current = $this->currentChannelVersionRow($channelId);
$frontendVersionId = (int)($current['frontend_version_id'] ?? 0) ?: null;
@@ -4851,43 +5466,29 @@ class release_manager
[$repository, $branch]
);
$deployments = [];
$operations = [];
if ($mappedChannel !== null && $mappedApp !== '') {
$operations[] = $this->syncChannel((int)$mappedChannel['id'], null, [
'app' => $mappedApp,
'source' => 'github_webhook',
'repository' => $repository,
'commit_sha' => $commitSha,
'build_url' => (string)($payload['compare'] ?? ''),
]);
}
$autoSyncEvents = [];
foreach ($targets as $target) {
if ($mappedChannel !== null && $mappedApp !== '') {
continue;
}
$deployments[] = $this->startDeployment([
'target_id' => (int)$target['id'],
$autoSyncEvents[] = $this->publicReleaseAutoSyncEvent($this->upsertReleaseAutoSyncEvent([
'channel_id' => (int)$target['channel_id'],
'app' => (string)$target['app'],
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'version_label' => substr($commitSha, 0, 12),
'build_url' => (string)($payload['compare'] ?? ''),
'status' => 'pending',
'source' => 'github_webhook',
'workflow_url' => (string)($payload['compare'] ?? ''),
'metadata' => [
'github_event' => $event,
'head_commit' => self::redactPayload($payload['head_commit'] ?? []),
],
]);
]));
}
$this->audit(null, null, 'github_webhook_processed', null, 'info', [
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'deployment_count' => count($deployments),
'auto_sync_event_count' => count($autoSyncEvents),
]);
return [
@@ -4897,8 +5498,9 @@ class release_manager
'commit_sha' => $commitSha,
'mapped_channel_slug' => $mappedChannelSlug,
'mapped_app' => $mappedApp !== '' ? $mappedApp : null,
'operations' => $operations,
'deployments' => $deployments,
'auto_sync_events' => $autoSyncEvents,
'operations' => [],
'deployments' => [],
];
}
@@ -7596,6 +8198,16 @@ class release_manager
$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);
if ($latestCommitSha !== '' && $commitSha !== '' && $commitSha !== $latestCommitSha) {
$comparison = $this->githubRequest(
'GET',
'/repos/' . $this->githubRepositoryPath($repository) . '/compare/' . rawurlencode($commitSha) . '...' . rawurlencode($latestCommitSha)
);
$comparisonStatus = (string)($comparison['status'] ?? '');
if (!in_array($comparisonStatus, ['behind', 'identical'], true)) {
throw new RuntimeException(sprintf('Commit %s is not reachable from branch %s.', $commitSha, $branch));
}
}
}
return [
@@ -118,6 +118,35 @@ class release_manager_schema_bootstrap
INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_auto_sync_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
app VARCHAR(16) NOT NULL,
repository VARCHAR(255) NOT NULL,
branch VARCHAR(128) NOT NULL,
commit_sha VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
source VARCHAR(64) NULL,
workflow_url VARCHAR(512) NULL,
gate_operation_id BIGINT UNSIGNED NULL,
sync_operation_id BIGINT UNSIGNED NULL,
deployment_id BIGINT UNSIGNED NULL,
error_message TEXT NULL,
metadata_json LONGTEXT NULL,
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
gate_passed_at DATETIME NULL,
synced_at DATETIME NULL,
promoted_at DATETIME NULL,
failed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_release_auto_sync_event (channel_id, app, repository, branch, commit_sha),
INDEX idx_release_auto_sync_channel_status (channel_id, status, updated_at),
INDEX idx_release_auto_sync_gate (gate_operation_id),
INDEX idx_release_auto_sync_sync (sync_operation_id),
INDEX idx_release_auto_sync_deployment (deployment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_service_sets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
@@ -408,6 +437,7 @@ class release_manager_schema_bootstrap
'release_versions',
'release_channel_versions',
'release_assignments',
'release_auto_sync_events',
'release_service_sets',
'release_deployments',
'release_bundles',
@@ -139,6 +139,28 @@ class selfserve_schema_bootstrap
UNIQUE KEY uniq_selfserve_vhw_department (department_id),
INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
lane_id INT NULL,
vehicle_type_id INT NULL,
config_version_id INT NULL,
config_source VARCHAR(32) NOT NULL DEFAULT 'draft',
path_signature VARCHAR(128) NOT NULL,
result_signature VARCHAR(128) NOT NULL,
answers_json JSON NOT NULL,
result_json JSON NOT NULL,
scope_json JSON NULL,
confirmed_by INT NULL,
confirmed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
stale_reason VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_selfserve_path_conf_department_scope (department_id, lane_id, vehicle_type_id, config_version_id),
INDEX idx_selfserve_path_conf_signature (department_id, config_version_id, path_signature)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
File diff suppressed because it is too large Load Diff
+126 -3
View File
@@ -5460,7 +5460,7 @@ paths:
tags:
- Self-Serve
summary: Bulk save all-in-one self-serve studio graph changes
description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, and tasks by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; layout remains separate from runtime behavior.
description: Creates, updates, deletes, connects, disconnects, reorders, and upserts self-serve answer paths by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; Path Editor upserts create normal generated condition and task nodes; layout remains separate from runtime behavior.
operationId: saveSelfserveStudioGraph
requestBody:
required: true
@@ -5617,6 +5617,29 @@ paths:
'422':
$ref: '#/components/responses/BadRequest'
/department/selfserve/studio/path-confirmations:
post:
tags:
- Self-Serve
summary: Confirm or reset a projected self-serve studio path
description: Stores confirmation for a projected terminal path using its stable path and result signatures. Projections report confirmed, unconfirmed, or stale when the resulting tasks, buttons, services, or signals change.
operationId: confirmSelfserveStudioPath
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioPathConfirmationRequest'
responses:
'200':
description: Path confirmation updated
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
'422':
$ref: '#/components/responses/BadRequest'
/department/selfserve/studio/publish:
post:
tags:
@@ -17898,10 +17921,10 @@ components:
properties:
action:
type: string
enum: [create, update, delete, connect, disconnect, reorder]
enum: [create, update, delete, connect, disconnect, reorder, upsert, upsert_path]
entity:
type: string
enum: [question, condition, task]
enum: [question, condition, task, action, path]
description: Standalone rule operations are not accepted for schema_version 2 drafts.
id:
type: integer
@@ -18288,6 +18311,8 @@ components:
items: { type: integer }
max_states: { type: integer }
path_sample_count: { type: integer }
confirmations:
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
outcomes:
type: array
items:
@@ -18303,6 +18328,15 @@ components:
type: boolean
progress:
$ref: '#/components/schemas/SelfserveStudioPathProgress'
confirmations:
type: object
properties:
summary:
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
removed:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
SelfserveStudioPathProgress:
type: object
@@ -18388,6 +18422,95 @@ components:
node_ids:
type: array
items: { type: string }
path_signature: { type: string }
result_signature: { type: string }
confirmation_status:
type: string
enum: [unconfirmed, confirmed, stale]
confirmed_at:
type: string
nullable: true
confirmed_by:
type: integer
nullable: true
stale_reason:
type: string
nullable: true
SelfserveStudioPathConfirmationSummary:
type: object
properties:
confirmed: { type: integer }
unconfirmed: { type: integer }
stale: { type: integer }
removed: { type: integer }
total: { type: integer }
SelfserveStudioPathConfirmationRequest:
type: object
required: [department, path_signature]
properties:
department: { type: integer }
action:
type: string
enum: [confirm, reset, delete, clear]
default: confirm
path_signature: { type: string }
result_signature:
type: string
description: Required when action is confirm.
scope:
type: object
additionalProperties: true
answers:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
result:
type: object
additionalProperties: true
SelfserveStudioPathConfirmation:
type: object
properties:
id:
type: integer
nullable: true
department_id: { type: integer }
lane_id:
type: integer
nullable: true
vehicle_type_id:
type: integer
nullable: true
config_version_id:
type: integer
nullable: true
config_source: { type: string }
path_signature: { type: string }
result_signature: { type: string }
confirmation_status:
type: string
enum: [unconfirmed, confirmed, stale]
answers:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
result:
type: object
additionalProperties: true
scope:
type: object
additionalProperties: true
confirmed_at:
type: string
nullable: true
confirmed_by:
type: integer
nullable: true
stale_reason:
type: string
nullable: true
SelfserveStudioPathTask:
type: object
@@ -195,6 +195,29 @@ class departmentSelfserveStudioRoute
'list_department_selfserve_vehicle_conditions' => 'Project grouped self-serve studio question path outcomes',
]);
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department', 'path_signature']);
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId);
try {
$payload = self::getParametersAsArray();
$action = strtolower(trim((string)($payload['action'] ?? 'confirm')));
$service = new selfserve_studio_graph();
$result = in_array($action, ['delete', 'reset', 'clear'], true)
? $service->resetPathConfirmation($departmentId, $payload)
: $service->confirmPathOutcome($departmentId, $payload, (int)$user->id);
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'CONFIRM_STUDIO_PATH', 'Updated self-serve studio path confirmation');
$response->success($result);
} catch (\RuntimeException $exception) {
$response->error($exception->getMessage(), 422);
}
}, [
'edit_department_selfserve_config_versions' => 'Confirm or reset projected self-serve studio answer paths',
]);
$this->post('/department/selfserve/studio/publish', function (): void {
global $response;
$user = $this->requireStudioUser('publish_department_selfserve_config_versions');
+3
View File
@@ -2,6 +2,7 @@
namespace routes;
use classes\release_manager;
use traits\route_t;
class pingRoute
@@ -15,6 +16,8 @@ class pingRoute
$response->success([
'message' => 'pong',
'time' => date('c'),
'backend_version' => release_manager::backendVersion(),
'api_commit_sha' => release_manager::backendCommitSha(),
]);
});
}
+3 -1
View File
@@ -17,5 +17,7 @@ it('returns the ping contract', function (): void {
expect($response->data())
->toBeArray()
->toHaveKey('message', 'pong')
->toHaveKey('time');
->toHaveKey('time')
->toHaveKey('backend_version')
->toHaveKey('api_commit_sha');
});
@@ -62,6 +62,40 @@ it('verifies CI release gate bearer tokens from dedicated release credentials',
}
});
it('normalizes app-specific release gate auto-sync metadata', function (): void {
$manager = new release_manager();
$normalizeGate = new ReflectionMethod(release_manager::class, 'normalizeReleaseGateInput');
$normalizeGate->setAccessible(true);
$appMatches = new ReflectionMethod(release_manager::class, 'releaseGateAppMatches');
$appMatches->setAccessible(true);
$gate = $normalizeGate->invoke($manager, [
'channel_slug' => 'stable',
'app' => 'api',
'repository' => 'https://github.com/copenhagentruckwash/api.git',
'branch' => 'master',
'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
'auto_sync' => true,
], ['slug' => 'stable']);
expect($gate)->toMatchArray([
'channel_slug' => 'stable',
'route_slug' => 'master',
'app' => 'api',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
'auto_sync' => true,
]);
expect($appMatches->invoke($manager, ['app' => 'api'], 'api'))->toBeTrue();
expect($appMatches->invoke($manager, ['apps' => ['frontend', 'api']], 'api'))->toBeTrue();
expect($appMatches->invoke($manager, [], 'frontend'))->toBeTrue();
expect($appMatches->invoke($manager, [], 'api'))->toBeFalse();
});
it('normalizes GitHub repository identifiers for private repository access checks', function (): void {
expect(release_manager::normalizeGithubRepositoryName('truckwash/backend-php'))->toBe('truckwash/backend-php');
expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue');
@@ -727,6 +761,8 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channel_versions');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_assignments');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployment_targets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_auto_sync_events');
expect($schema)->toContain('UNIQUE KEY uq_release_auto_sync_event');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_service_sets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployments');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_bundles');
@@ -796,6 +832,9 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain('verifyGithubSignature');
expect($manager)->toContain('verifyReleaseGateToken');
expect($manager)->toContain('normalizeReleaseGateInput');
expect($manager)->toContain('processReleaseGateAutoSync');
expect($manager)->toContain('release_auto_sync_events');
expect($manager)->toContain('require_readiness');
expect($manager)->toContain('release-manifest.json');
expect($manager)->toContain('static_artifact');
expect($manager)->toContain('api_gateway');
@@ -76,6 +76,7 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo
expect($content)->toContain('/department/selfserve/studio/simulate:');
expect($content)->toContain('/department/selfserve/studio/path-outcomes:');
expect($content)->toContain('/department/selfserve/studio/path-outcomes/stream:');
expect($content)->toContain('/department/selfserve/studio/path-confirmations:');
expect($content)->toContain('/department/selfserve/studio/publish:');
expect($content)->toContain('/department/selfserve/studio/rollback:');
expect($content)->toContain('/department/selfserve/studio/gateway-action:');
@@ -86,8 +87,11 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo
expect($content)->toContain('SelfserveStudioPathOutcomesResponse:');
expect($content)->toContain('SelfserveStudioPathResult:');
expect($content)->toContain('SelfserveStudioPathProgress:');
expect($content)->toContain('SelfserveStudioPathConfirmationRequest:');
expect($content)->toContain('SelfserveStudioPathConfirmation:');
expect($content)->toContain('projectSelfserveStudioPathOutcomes');
expect($content)->toContain('streamSelfserveStudioPathOutcomes');
expect($content)->toContain('confirmSelfserveStudioPath');
expect($content)->toContain('runSelfserveStudioGatewayAction');
});
@@ -108,16 +108,19 @@ it('wires the all-in-one self-serve studio replacement endpoints', function ():
expect($studioRoute)->toContain('/department/selfserve/studio/simulate');
expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes');
expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes/stream');
expect($studioRoute)->toContain('/department/selfserve/studio/path-confirmations');
expect($studioRoute)->toContain("ini_set('display_errors', '0')");
expect($studioRoute)->toContain('/department/selfserve/studio/publish');
expect($studioRoute)->toContain('/department/selfserve/studio/rollback');
expect($studioRoute)->toContain('/department/selfserve/studio/gateway-action');
expect($studioRoute)->toContain('projectPathOutcomes');
expect($studioRoute)->toContain('confirmPathOutcome');
expect($studioRoute)->toContain('modules_shelly_config');
expect($studioRoute)->toContain('modules_selfserve_sessions_force_stop');
expect($studioGraph)->not->toBeFalse();
expect($studioGraph)->toContain('department_selfserve_studio_layouts');
expect($studioGraph)->toContain('department_selfserve_path_confirmations');
expect($studioGraph)->toContain('buildGatewayWorkspace');
expect($studioGraph)->toContain('runGatewayAction');
expect($studioGraph)->toContain('layout_affects_runtime');
@@ -50,3 +50,13 @@ it('uses mysql-safe identifiers for self-serve studio virtual hardware storage',
expect(strlen($identifier))->toBeLessThanOrEqual(64);
}
});
it('creates self-serve studio path confirmation storage', function (): void {
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
expect($bootstrapContent)->not->toBeFalse();
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations');
expect($bootstrapContent)->toContain('path_signature VARCHAR(128) NOT NULL');
expect($bootstrapContent)->toContain('result_signature VARCHAR(128) NOT NULL');
expect($bootstrapContent)->toContain('idx_selfserve_path_conf_signature');
});
@@ -370,6 +370,168 @@ it('keeps runtime on published v2 configs and leaves draft JSON as the studio ed
expect($washFlowSource)->toContain("\$configSource = \$publishedConfigPayload === null ? 'legacy' : 'published';");
expect($studioGraphSource)->toContain('$draftObject->config_json->set($config);');
expect($studioGraphSource)->toContain('Standalone rule operations are not supported in self-serve rules v2.');
expect($studioGraphSource)->toContain('upsert_path');
});
it('upserts path editor answers into generated condition and task config rows', function (): void {
$service = selfserve_studio_graph_without_constructor();
$method = new ReflectionMethod(selfserve_studio_graph::class, 'applyConfigOperation');
$method->setAccessible(true);
$config = [
'schema_version' => 2,
'questions' => [
['id' => 11, 'question' => 'Machine wash is allowed', 'order_priority' => 1],
['id' => 12, 'question' => 'Trailer present', 'order_priority' => 2],
],
'conditions' => [],
'rules' => [],
'tasks' => [],
'actions' => [],
'v2_meta' => ['next_ids' => ['condition' => 100, 'task' => 200]],
];
$method->invokeArgs($service, [6, &$config, [
'action' => 'upsert_path',
'entity' => 'path',
'data' => [
'path_key' => 'allowed_front',
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
'answers' => [
['question_id' => 11, 'value' => true],
['question_id' => 12, 'value' => false],
],
'result' => [
'machine_allowed' => true,
'task' => 'Set program',
'services' => ['MACHINE', 'PROGRAM_PICKER'],
'buttons' => ['program_picker', 'reset', 2, 'start'],
'dynamic_images_vehicle_type' => 3,
'tasks' => [
[
'task' => 'Set program',
'services' => ['MACHINE', 'PROGRAM_PICKER'],
'buttons' => ['program_picker'],
'dynamic_images_vehicle_type' => 3,
],
[
'task' => 'Press reset',
'services' => ['MACHINE'],
'buttons' => ['reset'],
],
[
'task' => 'Press machine button 2',
'services' => ['MACHINE'],
'buttons' => [2],
],
[
'task' => 'Press start',
'services' => ['MACHINE'],
'buttons' => ['start'],
],
],
],
],
]]);
$condition = $config['conditions'][0] ?? [];
$tasks = array_values($config['tasks'] ?? []);
$pathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? [];
$validation = (new class extends selfserve_config_versioning {
public function __construct()
{
}
})->validateConfig($config);
expect($condition['generated_by'])->toBe('path_editor')
->and($condition['path_key'])->toBe('allowed_front')
->and($condition['lane'])->toBe(7)
->and($condition['product'])->toBe(2)
->and($condition['machine_type_id'])->toBe(1001)
->and($condition['expression']['children'])->toHaveCount(2)
->and($condition['expression']['children'][0]['operator'])->toBe('IS_TRUE')
->and($condition['expression']['children'][1]['operator'])->toBe('IS_FALSE')
->and($tasks)->toHaveCount(4)
->and(array_column($tasks, 'task'))->toBe(['Set program', 'Press reset', 'Press machine button 2', 'Press start'])
->and(array_column($tasks, 'order_priority'))->toBe([10, 20, 30, 40])
->and(array_column($tasks, 'gate_ref_id'))->toBe([(int)$condition['id'], (int)$condition['id'], (int)$condition['id'], (int)$condition['id']])
->and($tasks[0]['generated_by'])->toBe('path_editor')
->and($tasks[0]['gate_type'])->toBe('CONDITION')
->and($tasks[0]['services'])->toBe(['MACHINE', 'PROGRAM_PICKER'])
->and($tasks[0]['buttons'])->toBe(['program_picker'])
->and($tasks[0]['dynamic_images_vehicle_type'])->toBe(3)
->and($tasks[1]['buttons'])->toBe(['reset'])
->and($tasks[2]['buttons'])->toBe([2])
->and($tasks[3]['buttons'])->toBe(['start'])
->and($pathMeta['condition_id'])->toBe((int)$condition['id'])
->and($pathMeta['task_id'])->toBe((int)$tasks[0]['id'])
->and($pathMeta['task_ids'])->toBe(array_map(static fn(array $task): int => (int)$task['id'], $tasks))
->and($pathMeta['result']['buttons'])->toBe(['program_picker', 'reset', 2, 'start'])
->and($pathMeta['path_signature'])->not->toBe('')
->and($validation['valid'])->toBeTrue();
$method->invokeArgs($service, [6, &$config, [
'action' => 'upsert_path',
'entity' => 'path',
'data' => [
'path_key' => 'allowed_front',
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
'answers' => [
['question_id' => 11, 'value' => true],
['question_id' => 12, 'value' => false],
],
'result' => [
'machine_allowed' => true,
'task' => 'Set program',
'services' => ['MACHINE', 'PROGRAM_PICKER'],
'buttons' => ['program_picker', 'start'],
'dynamic_images_vehicle_type' => 3,
'tasks' => [
['task' => 'Set program', 'services' => ['MACHINE', 'PROGRAM_PICKER'], 'buttons' => ['program_picker'], 'dynamic_images_vehicle_type' => 3],
['task' => 'Press start', 'services' => ['MACHINE'], 'buttons' => ['start']],
],
],
],
]]);
$updatedTasks = array_values(array_filter(
$config['tasks'] ?? [],
static fn(array $task): bool => ($task['path_key'] ?? '') === 'allowed_front'
));
$updatedPathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? [];
expect($updatedTasks)->toHaveCount(2)
->and(array_column($updatedTasks, 'id'))->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']])
->and(array_column($updatedTasks, 'task'))->toBe(['Set program', 'Press start'])
->and($updatedPathMeta['task_ids'])->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']]);
$method->invokeArgs($service, [6, &$config, [
'action' => 'upsert_path',
'entity' => 'path',
'data' => [
'path_key' => 'legacy_front',
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
'answers' => [
['question_id' => 11, 'value' => false],
],
'result' => [
'machine_allowed' => true,
'task' => 'Legacy start',
'services' => ['MACHINE'],
'buttons' => ['start'],
],
],
]]);
$legacyPathMeta = $config['v2_meta']['path_editor']['paths']['legacy_front'] ?? [];
$legacyTask = array_values(array_filter(
$config['tasks'] ?? [],
static fn(array $task): bool => ($task['path_key'] ?? '') === 'legacy_front'
))[0] ?? [];
expect($legacyTask['task'])->toBe('Legacy start')
->and($legacyTask['buttons'])->toBe(['start'])
->and($legacyPathMeta['task_id'])->toBe((int)$legacyTask['id'])
->and($legacyPathMeta['task_ids'])->toBe([(int)$legacyTask['id']]);
});
it('surfaces task attachments in studio graph, simulator, and flow responses', function (): void {
@@ -1016,10 +1178,93 @@ it('projects visible question answer paths into grouped task service and signal
->and($allowedPath['answers'])->toHaveCount(2)
->and($allowedPath['answers'][0]['question'])->toBe('Are mirrors folded?')
->and($allowedPath['services'])->toBe(['MACHINE'])
->and($allowedPath['path_signature'])->not->toBe('')
->and($allowedPath['result_signature'])->not->toBe('')
->and($allowedPath['confirmation_status'])->toBe('unconfirmed')
->and($allowedPath['node_ids'])->toContain('question:11')
->and($allowedPath['node_ids'])->toContain('task:41');
});
it('marks projected path confirmations confirmed or stale by stable signatures', function (): void {
$service = selfserve_studio_graph_without_constructor();
$simulate = static function (string $button): callable {
return static function (array $overrides) use ($button): array {
$answers = [];
foreach ($overrides as $entry) {
$answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
}
$allowed = ($answers[11] ?? null) === true;
return [
'allowed' => $allowed,
'questions' => [
['id' => 11, 'question' => 'Machine wash is allowed', 'answer' => $answers[11] ?? null],
],
'tasks' => $allowed ? [
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => [$button]],
] : [],
'allowed_services' => $allowed ? ['MACHINE'] : [],
'debug' => [
'questions' => [
['id' => 11, 'node_id' => 'question:11', 'label' => 'Machine wash is allowed', 'visible' => true, 'answer' => $answers[11] ?? null],
],
'tasks' => [
['id' => 41, 'node_id' => 'task:41', 'label' => 'Start machine', 'active' => $allowed, 'services' => ['MACHINE'], 'buttons' => [$button], 'order_priority' => 1],
],
'signal_timeline' => [],
],
];
};
};
$scope = [
'department_id' => 6,
'lane_id' => 7,
'vehicle_type_id' => 2,
'config_source' => 'draft',
'config_version_id' => 90,
'hardware_mode' => 'studio',
];
$initial = $service->projectPathOutcomesFromSimulator($simulate('start'), ['scope' => $scope]);
$allowedPath = array_values(array_filter(
$initial['paths'],
static fn(array $path): bool => ($path['allowed'] ?? false) === true
))[0] ?? [];
$rows = [[
'path_signature' => $allowedPath['path_signature'],
'result_signature' => $allowedPath['result_signature'],
'answers' => $allowedPath['answers'],
'result' => ['allowed' => true],
'scope' => $scope,
'confirmed_at' => '2026-05-27 10:00:00',
'confirmed_by' => 9,
]];
$confirmed = $service->projectPathOutcomesFromSimulator($simulate('start'), [
'scope' => $scope,
'confirmation_rows' => $rows,
]);
$changed = $service->projectPathOutcomesFromSimulator($simulate('reset'), [
'scope' => $scope,
'confirmation_rows' => $rows,
]);
$confirmedAllowed = array_values(array_filter(
$confirmed['paths'],
static fn(array $path): bool => ($path['allowed'] ?? false) === true
))[0] ?? [];
$staleAllowed = array_values(array_filter(
$changed['paths'],
static fn(array $path): bool => ($path['allowed'] ?? false) === true
))[0] ?? [];
expect($confirmedAllowed['confirmation_status'])->toBe('confirmed')
->and($confirmed['summary']['confirmations']['confirmed'])->toBe(1)
->and($staleAllowed['confirmation_status'])->toBe('stale')
->and($staleAllowed['stale_reason'])->toBe('Result changed since confirmation.')
->and($changed['summary']['confirmations']['stale'])->toBe(1);
});
it('truncates path outcome projection when the state cap is reached', function (): void {
$service = selfserve_studio_graph_without_constructor();
$simulate = function (array $overrides): array {