Fix frontend release version recording (#342)

Add a scoped release-gate endpoint for exact frontend SHA recording and independent readback.
This commit is contained in:
Jeppe B
2026-08-03 16:11:09 +02:00
committed by GitHub
parent 1f03b46564
commit 842b06c93f
3 changed files with 120 additions and 0 deletions
@@ -378,6 +378,39 @@ class release_manager
return $expected !== '' && hash_equals($expected, $token); 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 public static function normalizeGithubRepositoryName(string $value): string
{ {
$repository = trim($value); $repository = trim($value);
@@ -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 () { $this->get('/superuser/releases', function () {
global $response; global $response;
$this->requirePermission('superuser_release_manager_view'); $this->requirePermission('superuser_release_manager_view');
@@ -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 { it('normalizes app-specific release gate auto-sync metadata', function (): void {
$manager = new release_manager(); $manager = new release_manager();
$normalizeGate = new ReflectionMethod(release_manager::class, 'normalizeReleaseGateInput'); $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/timeline/events');
expect($route)->toContain('/release/github/webhook'); expect($route)->toContain('/release/github/webhook');
expect($route)->toContain('/release/gate/test-runs'); expect($route)->toContain('/release/gate/test-runs');
expect($route)->toContain('/release/gate/frontend-version');
expect($route)->toContain('releaseGateToken'); 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/config');
expect($route)->toContain('/superuser/releases/github/repositories'); expect($route)->toContain('/superuser/releases/github/repositories');
expect($route)->toContain('/superuser/releases/github/branches'); expect($route)->toContain('/superuser/releases/github/branches');