feat(deploy): 接入全自动生产发布校验
This commit is contained in:
@@ -9,9 +9,10 @@ import { ConfigModule } from './modules/config/config.module';
|
||||
import { DataModule } from './modules/data/data.module';
|
||||
import { MigrationModule } from './modules/migration/migration.module';
|
||||
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, AiModule],
|
||||
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule],
|
||||
controllers: [],
|
||||
providers: [
|
||||
{
|
||||
|
||||
19
apps/server/src/modules/health/health.controller.spec.ts
Normal file
19
apps/server/src/modules/health/health.controller.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
describe('HealthController', () => {
|
||||
it('returns runtime version metadata from the health service', () => {
|
||||
const service = {
|
||||
getVersion: jest.fn().mockReturnValue({
|
||||
service: 'server',
|
||||
version: 'commit-456',
|
||||
buildTime: '2026-07-06T14:00:00.000Z',
|
||||
imageTag: 'ghcr.io/acme/ftb/server:commit-456',
|
||||
}),
|
||||
};
|
||||
|
||||
const result = new HealthController(service as any).getVersion();
|
||||
|
||||
expect(result.version).toBe('commit-456');
|
||||
expect(service.getVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
12
apps/server/src/modules/health/health.controller.ts
Normal file
12
apps/server/src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get('version')
|
||||
getVersion() {
|
||||
return this.healthService.getVersion();
|
||||
}
|
||||
}
|
||||
9
apps/server/src/modules/health/health.module.ts
Normal file
9
apps/server/src/modules/health/health.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
43
apps/server/src/modules/health/health.service.spec.ts
Normal file
43
apps/server/src/modules/health/health.service.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
describe('HealthService', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('returns deploy version metadata from environment variables', () => {
|
||||
process.env.APP_VERSION = 'commit-123';
|
||||
process.env.APP_BUILD_TIME = '2026-07-06T13:00:00.000Z';
|
||||
process.env.APP_IMAGE_TAG = 'ghcr.io/acme/ftb/server:commit-123';
|
||||
|
||||
const result = new HealthService().getVersion();
|
||||
|
||||
expect(result).toEqual({
|
||||
service: 'server',
|
||||
version: 'commit-123',
|
||||
buildTime: '2026-07-06T13:00:00.000Z',
|
||||
imageTag: 'ghcr.io/acme/ftb/server:commit-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses stable fallback values when deploy metadata is not configured', () => {
|
||||
delete process.env.APP_VERSION;
|
||||
delete process.env.APP_BUILD_TIME;
|
||||
delete process.env.APP_IMAGE_TAG;
|
||||
|
||||
const result = new HealthService().getVersion();
|
||||
|
||||
expect(result).toEqual({
|
||||
service: 'server',
|
||||
version: 'unknown',
|
||||
buildTime: '',
|
||||
imageTag: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
20
apps/server/src/modules/health/health.service.ts
Normal file
20
apps/server/src/modules/health/health.service.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
export interface RuntimeVersion {
|
||||
service: 'server';
|
||||
version: string;
|
||||
buildTime: string;
|
||||
imageTag: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
getVersion(): RuntimeVersion {
|
||||
return {
|
||||
service: 'server',
|
||||
version: process.env.APP_VERSION || 'unknown',
|
||||
buildTime: process.env.APP_BUILD_TIME || '',
|
||||
imageTag: process.env.APP_IMAGE_TAG || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
76
apps/web/components/RuntimeVersionBanner.tsx
Normal file
76
apps/web/components/RuntimeVersionBanner.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
getClientRuntimeVersion,
|
||||
shouldPromptForNewRuntimeVersion,
|
||||
type ServerRuntimeVersion,
|
||||
} from '@/lib/runtime-version';
|
||||
|
||||
const VERSION_POLL_MS = 60_000;
|
||||
|
||||
export function RuntimeVersionBanner() {
|
||||
const clientVersion = useMemo(() => getClientRuntimeVersion(), []);
|
||||
const [serverVersion, setServerVersion] = useState<ServerRuntimeVersion | null>(null);
|
||||
const [dismissedVersion, setDismissedVersion] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const next = await api.get<ServerRuntimeVersion>('/health/version');
|
||||
if (!cancelled) setServerVersion(next);
|
||||
} catch {
|
||||
if (!cancelled) setServerVersion(null);
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
timer = setInterval(() => void load(), VERSION_POLL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shouldShow =
|
||||
serverVersion &&
|
||||
serverVersion.version !== dismissedVersion &&
|
||||
shouldPromptForNewRuntimeVersion(clientVersion.version, serverVersion.version);
|
||||
|
||||
if (!shouldShow || !serverVersion) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[70] w-[320px] rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-blue-900 shadow-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<RefreshCw className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-semibold">发现新版本</p>
|
||||
<p className="mt-1 text-[12px] leading-5 text-blue-700">
|
||||
服务器已更新到 {serverVersion.version.slice(0, 7)},刷新后可使用最新前端。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-2 h-7 rounded-md bg-blue-600 px-3 text-[12px] font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissedVersion(serverVersion.version)}
|
||||
className="rounded-md p-1 text-blue-500 hover:bg-blue-100 hover:text-blue-700"
|
||||
aria-label="关闭新版本提示"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { usePathname } from 'next/navigation';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { ServerDataSaveErrorBanner } from '@/components/ServerDataSaveErrorBanner';
|
||||
import { RuntimeVersionBanner } from '@/components/RuntimeVersionBanner';
|
||||
|
||||
export function LayoutShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
@@ -12,6 +13,7 @@ export function LayoutShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<ServerDataSaveErrorBanner />
|
||||
<RuntimeVersionBanner />
|
||||
{isLoginPage ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
|
||||
43
apps/web/lib/production-deploy-source.test.ts
Normal file
43
apps/web/lib/production-deploy-source.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const repoRoot = join(process.cwd(), '../..');
|
||||
|
||||
test('production Dockerfiles embed runtime version metadata', () => {
|
||||
const webDockerfile = readFileSync(join(repoRoot, 'Dockerfile.web'), 'utf8');
|
||||
const serverDockerfile = readFileSync(join(repoRoot, 'Dockerfile.server'), 'utf8');
|
||||
|
||||
assert.match(webDockerfile, /ARG APP_VERSION=unknown/);
|
||||
assert.match(webDockerfile, /ENV NEXT_PUBLIC_APP_VERSION=\$APP_VERSION/);
|
||||
assert.match(serverDockerfile, /ARG APP_VERSION=unknown/);
|
||||
assert.match(serverDockerfile, /ENV APP_VERSION=\$APP_VERSION/);
|
||||
});
|
||||
|
||||
test('production compose can pull immutable web and server images', () => {
|
||||
const compose = readFileSync(join(repoRoot, 'docker-compose.prod.yml'), 'utf8');
|
||||
|
||||
assert.match(compose, /image: \$\{SERVER_IMAGE:\?Set SERVER_IMAGE/);
|
||||
assert.match(compose, /image: \$\{WEB_IMAGE:\?Set WEB_IMAGE/);
|
||||
assert.match(compose, /APP_VERSION: \$\{APP_VERSION:-unknown\}/);
|
||||
});
|
||||
|
||||
test('GitHub Actions workflow builds pushes deploys migrates and verifies runtime version', () => {
|
||||
const workflowPath = join(repoRoot, '.github/workflows/deploy-production.yml');
|
||||
assert.equal(existsSync(workflowPath), true);
|
||||
const workflow = readFileSync(workflowPath, 'utf8');
|
||||
|
||||
assert.match(workflow, /docker\/build-push-action/);
|
||||
assert.match(workflow, /appleboy\/ssh-action/);
|
||||
assert.match(workflow, /docker compose --env-file \.env\.production -f docker-compose\.prod\.yml pull/);
|
||||
assert.match(workflow, /pnpm --filter server db:deploy/);
|
||||
assert.match(workflow, /\/api\/v1\/health\/version/);
|
||||
});
|
||||
|
||||
test('deployment runtime version check is available as a repeatable script', () => {
|
||||
const packageJson = readFileSync(join(repoRoot, 'package.json'), 'utf8');
|
||||
|
||||
assert.equal(existsSync(join(repoRoot, 'scripts/check-runtime-version.mjs')), true);
|
||||
assert.match(packageJson, /"deploy:check-runtime": "node scripts\/check-runtime-version\.mjs"/);
|
||||
});
|
||||
34
apps/web/lib/runtime-version.test.ts
Normal file
34
apps/web/lib/runtime-version.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import test from 'node:test';
|
||||
import { getClientRuntimeVersion, shouldPromptForNewRuntimeVersion } from './runtime-version';
|
||||
|
||||
test('getClientRuntimeVersion reads web build metadata from public environment variables', () => {
|
||||
const result = getClientRuntimeVersion({
|
||||
NEXT_PUBLIC_APP_VERSION: 'commit-789',
|
||||
NEXT_PUBLIC_APP_BUILD_TIME: '2026-07-06T15:00:00.000Z',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
service: 'web',
|
||||
version: 'commit-789',
|
||||
buildTime: '2026-07-06T15:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('getClientRuntimeVersion uses stable fallback values without build metadata', () => {
|
||||
const result = getClientRuntimeVersion({});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
service: 'web',
|
||||
version: 'unknown',
|
||||
buildTime: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('shouldPromptForNewRuntimeVersion only prompts when both versions are known and different', () => {
|
||||
assert.equal(shouldPromptForNewRuntimeVersion('commit-a', 'commit-b'), true);
|
||||
assert.equal(shouldPromptForNewRuntimeVersion('commit-a', 'commit-a'), false);
|
||||
assert.equal(shouldPromptForNewRuntimeVersion('unknown', 'commit-b'), false);
|
||||
assert.equal(shouldPromptForNewRuntimeVersion('commit-a', 'unknown'), false);
|
||||
assert.equal(shouldPromptForNewRuntimeVersion('', 'commit-b'), false);
|
||||
});
|
||||
36
apps/web/lib/runtime-version.ts
Normal file
36
apps/web/lib/runtime-version.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
type RuntimeEnv = {
|
||||
NEXT_PUBLIC_APP_VERSION?: string;
|
||||
NEXT_PUBLIC_APP_BUILD_TIME?: string;
|
||||
};
|
||||
|
||||
export interface ClientRuntimeVersion {
|
||||
service: 'web';
|
||||
version: string;
|
||||
buildTime: string;
|
||||
}
|
||||
|
||||
export interface ServerRuntimeVersion {
|
||||
service: 'server';
|
||||
version: string;
|
||||
buildTime: string;
|
||||
imageTag: string;
|
||||
}
|
||||
|
||||
export function getClientRuntimeVersion(
|
||||
env: RuntimeEnv = {
|
||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||
NEXT_PUBLIC_APP_BUILD_TIME: process.env.NEXT_PUBLIC_APP_BUILD_TIME,
|
||||
},
|
||||
): ClientRuntimeVersion {
|
||||
return {
|
||||
service: 'web',
|
||||
version: env.NEXT_PUBLIC_APP_VERSION || 'unknown',
|
||||
buildTime: env.NEXT_PUBLIC_APP_BUILD_TIME || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldPromptForNewRuntimeVersion(clientVersion: string, serverVersion: string): boolean {
|
||||
if (!clientVersion || !serverVersion) return false;
|
||||
if (clientVersion === 'unknown' || serverVersion === 'unknown') return false;
|
||||
return clientVersion !== serverVersion;
|
||||
}
|
||||
Reference in New Issue
Block a user