diff --git a/services/nginx/app/classes/release_manager.php b/services/nginx/app/classes/release_manager.php index 286de1dc..1ddcad4e 100644 --- a/services/nginx/app/classes/release_manager.php +++ b/services/nginx/app/classes/release_manager.php @@ -378,6 +378,39 @@ class release_manager return $expected !== '' && hash_equals($expected, $token); } + public static function normalizeFrontendVersionGateInput(array $payload): array + { + $version = strtolower(trim((string)($payload['version'] ?? ''))); + if (preg_match('/^[a-f0-9]{40}$/', $version) !== 1) { + throw new RuntimeException('Frontend release version must be a full commit SHA.'); + } + + $repository = self::normalizeGithubRepositoryName((string)($payload['repository'] ?? '')); + $expectedRepository = self::normalizeGithubRepositoryName( + self::runtimeEnvValue('RELEASE_MANAGER_FRONTEND_REPOSITORY') ?: 'copenhagentruckwash/pleno-vue' + ); + if ($repository === '' || strtolower($repository) !== strtolower($expectedRepository)) { + throw new RuntimeException('Frontend release repository is not authorized.'); + } + + $branch = strtolower(trim((string)($payload['branch'] ?? ''))); + if ($branch !== self::DEFAULT_BRANCH) { + throw new RuntimeException('Frontend release branch must be master.'); + } + + $buildId = trim((string)($payload['build_id'] ?? '')); + if (preg_match('/^[1-9][0-9]*-[1-9][0-9]*$/', $buildId) !== 1) { + throw new RuntimeException('Frontend release build id is invalid.'); + } + + return [ + 'version' => $version, + 'repository' => $repository, + 'branch' => $branch, + 'build_id' => $buildId, + ]; + } + public static function normalizeGithubRepositoryName(string $value): string { $repository = trim($value); diff --git a/services/nginx/app/routes/releaseManagerRoute.php b/services/nginx/app/routes/releaseManagerRoute.php index 6c92e1a9..2f34f74a 100644 --- a/services/nginx/app/routes/releaseManagerRoute.php +++ b/services/nginx/app/routes/releaseManagerRoute.php @@ -59,6 +59,44 @@ class releaseManagerRoute } }); + $this->post('/release/gate/frontend-version', function () { + global $response; + $manager = new release_manager(); + if (!$manager->verifyReleaseGateToken($this->releaseGateToken())) { + $response->error(['message' => 'Invalid release gate token.'], 401); + } + + try { + $release = release_manager::normalizeFrontendVersionGateInput($this->requestPayload()); + redis->set('worker_target_version', $release['version']); + $observed = (string)(redis->get('worker_target_version') ?? ''); + if (!hash_equals($release['version'], $observed)) { + throw new \RuntimeException('Frontend release version read-back failed.'); + } + + $response->success([ + 'version' => $observed, + 'repository' => $release['repository'], + 'branch' => $release['branch'], + 'build_id' => $release['build_id'], + ]); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 422); + } + }); + + $this->get('/release/gate/frontend-version', function () { + global $response; + $manager = new release_manager(); + if (!$manager->verifyReleaseGateToken($this->releaseGateToken())) { + $response->error(['message' => 'Invalid release gate token.'], 401); + } + + $response->success([ + 'version' => strtolower(trim((string)(redis->get('worker_target_version') ?? ''))), + ]); + }); + $this->get('/superuser/releases', function () { global $response; $this->requirePermission('superuser_release_manager_view'); diff --git a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php index 70b7d8f7..2e0e70b0 100644 --- a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php +++ b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php @@ -87,6 +87,51 @@ it('verifies CI release gate bearer tokens from dedicated release credentials', } }); +it('normalizes gate-authenticated frontend release version updates', function (): void { + $release = release_manager::normalizeFrontendVersionGateInput([ + 'version' => 'A' . str_repeat('b', 39), + 'repository' => 'https://github.com/copenhagentruckwash/pleno-vue.git', + 'branch' => 'master', + 'build_id' => '30818161014-2', + ]); + + expect($release)->toBe([ + 'version' => 'a' . str_repeat('b', 39), + 'repository' => 'copenhagentruckwash/pleno-vue', + 'branch' => 'master', + 'build_id' => '30818161014-2', + ]); +}); + +it('rejects untrusted frontend release version updates', function (array $payload, string $message): void { + expect(fn () => release_manager::normalizeFrontendVersionGateInput($payload))->toThrow(RuntimeException::class, $message); +})->with([ + 'short commit' => [[ + 'version' => 'deadbeef', + 'repository' => 'copenhagentruckwash/pleno-vue', + 'branch' => 'master', + 'build_id' => '123-1', + ], 'full commit SHA'], + 'wrong repository' => [[ + 'version' => str_repeat('a', 40), + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + 'build_id' => '123-1', + ], 'repository is not authorized'], + 'wrong branch' => [[ + 'version' => str_repeat('a', 40), + 'repository' => 'copenhagentruckwash/pleno-vue', + 'branch' => 'beta', + 'build_id' => '123-1', + ], 'branch must be master'], + 'invalid build id' => [[ + 'version' => str_repeat('a', 40), + 'repository' => 'copenhagentruckwash/pleno-vue', + 'branch' => 'master', + 'build_id' => '../release', + ], 'build id is invalid'], +]); + it('normalizes app-specific release gate auto-sync metadata', function (): void { $manager = new release_manager(); $normalizeGate = new ReflectionMethod(release_manager::class, 'normalizeReleaseGateInput'); @@ -1182,7 +1227,11 @@ it('defines release manager schema, routes, permissions, and system-status integ expect($route)->toContain('/release/timeline/events'); expect($route)->toContain('/release/github/webhook'); expect($route)->toContain('/release/gate/test-runs'); + expect($route)->toContain('/release/gate/frontend-version'); expect($route)->toContain('releaseGateToken'); + expect($route)->toContain('normalizeFrontendVersionGateInput'); + expect($route)->toContain("redis->set('worker_target_version'"); + expect($route)->toContain("redis->get('worker_target_version'"); expect($route)->toContain('/superuser/releases/config'); expect($route)->toContain('/superuser/releases/github/repositories'); expect($route)->toContain('/superuser/releases/github/branches');