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:
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user