feat(deploy): 接入全自动生产发布校验

This commit is contained in:
Script Generator
2026-07-06 14:45:44 +08:00
parent 8d61bb7c13
commit dc780e4c7d
24 changed files with 662 additions and 3 deletions

View File

@@ -2,6 +2,13 @@
COMPOSE_PROJECT_NAME=ftb_pm
# Deployment images and runtime metadata.
# GitHub Actions rewrites these values to immutable SHA image tags during deploy.
APP_VERSION=unknown
APP_BUILD_TIME=
WEB_IMAGE=ghcr.io/your-org/ftb-project-management/web:master
SERVER_IMAGE=ghcr.io/your-org/ftb-project-management/server:master
# Public entrypoint
SERVER_NAME=pm.example.com
HTTP_PORT=80

148
.github/workflows/deploy-production.yml vendored Normal file
View File

@@ -0,0 +1,148 @@
name: Deploy Production
on:
push:
branches:
- master
workflow_dispatch:
concurrency:
group: production
cancel-in-progress: false
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
NEXT_PUBLIC_API_URL: /api/v1
NEXT_API_PROXY_TARGET: http://server:3001
jobs:
build-push-deploy:
name: Build, push, deploy, verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare image metadata
id: meta
shell: bash
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
build_time="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo "repo_lc=${repo_lc}" >> "$GITHUB_OUTPUT"
echo "build_time=${build_time}" >> "$GITHUB_OUTPUT"
echo "web_image=${REGISTRY}/${repo_lc}/web:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "server_image=${REGISTRY}/${repo_lc}/server:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "web_image_master=${REGISTRY}/${repo_lc}/web:master" >> "$GITHUB_OUTPUT"
echo "server_image_master=${REGISTRY}/${repo_lc}/server:master" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push web image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.web
push: true
tags: |
${{ steps.meta.outputs.web_image }}
${{ steps.meta.outputs.web_image_master }}
build-args: |
NEXT_PUBLIC_API_URL=${{ env.NEXT_PUBLIC_API_URL }}
NEXT_API_PROXY_TARGET=${{ env.NEXT_API_PROXY_TARGET }}
APP_VERSION=${{ github.sha }}
APP_BUILD_TIME=${{ steps.meta.outputs.build_time }}
APP_IMAGE_TAG=${{ steps.meta.outputs.web_image }}
- name: Build and push server image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.server
push: true
tags: |
${{ steps.meta.outputs.server_image }}
${{ steps.meta.outputs.server_image_master }}
build-args: |
APP_VERSION=${{ github.sha }}
APP_BUILD_TIME=${{ steps.meta.outputs.build_time }}
APP_IMAGE_TAG=${{ steps.meta.outputs.server_image }}
- name: Deploy over SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script_stop: true
script: |
set -euo pipefail
cd "${{ secrets.PROD_APP_DIR }}"
test -f .env.production
git fetch origin master
git checkout master
git pull --ff-only origin master
set_env() {
key="$1"
value="$2"
if grep -q "^${key}=" .env.production; then
sed -i "s|^${key}=.*|${key}=${value}|" .env.production
else
printf "\n%s=%s\n" "${key}" "${value}" >> .env.production
fi
}
set_env APP_VERSION "${{ github.sha }}"
set_env APP_BUILD_TIME "${{ steps.meta.outputs.build_time }}"
set_env WEB_IMAGE "${{ steps.meta.outputs.web_image }}"
set_env SERVER_IMAGE "${{ steps.meta.outputs.server_image }}"
if [ -n "${{ secrets.GHCR_READ_TOKEN }}" ]; then
echo "${{ secrets.GHCR_READ_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
fi
docker compose --env-file .env.production -f docker-compose.prod.yml pull web server
docker compose --env-file .env.production -f docker-compose.prod.yml up -d postgres redis
docker compose --env-file .env.production -f docker-compose.prod.yml run --rm -T server pnpm --filter server db:deploy
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans
for attempt in $(seq 1 30); do
if docker compose --env-file .env.production -f docker-compose.prod.yml exec -T web node -e "
const expected = process.argv[1];
fetch('http://nginx/api/v1/health/version')
.then(async (response) => {
if (!response.ok) throw new Error('HTTP ' + response.status);
const payload = await response.json();
if (payload.version !== expected) {
throw new Error('Expected ' + expected + ', got ' + payload.version);
}
console.log('Runtime version verified: ' + payload.version);
})
.catch((error) => {
console.error(error.message);
process.exit(1);
});
" "${{ github.sha }}"; then
exit 0
fi
sleep 2
done
echo "Runtime version check failed after retries"
exit 1

View File

@@ -25,7 +25,16 @@ RUN pnpm --filter server exec prisma generate --schema prisma/schema.prisma
RUN pnpm --filter server build
FROM base AS runner
ARG APP_VERSION=unknown
ARG APP_BUILD_TIME=
ARG APP_IMAGE_TAG=
ENV NODE_ENV=production
ENV APP_VERSION=$APP_VERSION
ENV APP_BUILD_TIME=$APP_BUILD_TIME
ENV APP_IMAGE_TAG=$APP_IMAGE_TAG
LABEL org.opencontainers.image.revision=$APP_VERSION
LABEL org.opencontainers.image.created=$APP_BUILD_TIME
LABEL org.opencontainers.image.ref.name=$APP_IMAGE_TAG
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml

View File

@@ -19,8 +19,14 @@ RUN pnpm config set fetch-retries 5 \
FROM deps AS builder
ARG NEXT_PUBLIC_API_URL=/api/v1
ARG NEXT_API_PROXY_TARGET=
ARG APP_VERSION=unknown
ARG APP_BUILD_TIME=
ARG APP_IMAGE_TAG=
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_API_PROXY_TARGET=$NEXT_API_PROXY_TARGET
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION
ENV NEXT_PUBLIC_APP_BUILD_TIME=$APP_BUILD_TIME
ENV NEXT_PUBLIC_APP_IMAGE_TAG=$APP_IMAGE_TAG
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
COPY packages/shared packages/shared
@@ -31,10 +37,19 @@ RUN pnpm --filter web build
FROM base AS runner
ARG NEXT_PUBLIC_API_URL=/api/v1
ARG NEXT_API_PROXY_TARGET=
ARG APP_VERSION=unknown
ARG APP_BUILD_TIME=
ARG APP_IMAGE_TAG=
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_API_PROXY_TARGET=$NEXT_API_PROXY_TARGET
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION
ENV NEXT_PUBLIC_APP_BUILD_TIME=$APP_BUILD_TIME
ENV NEXT_PUBLIC_APP_IMAGE_TAG=$APP_IMAGE_TAG
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
LABEL org.opencontainers.image.revision=$APP_VERSION
LABEL org.opencontainers.image.created=$APP_BUILD_TIME
LABEL org.opencontainers.image.ref.name=$APP_IMAGE_TAG
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml

View File

@@ -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: [
{

View 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);
});
});

View 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();
}
}

View 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 {}

View 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: '',
});
});
});

View 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 || '',
};
}
}

View 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>
);
}

View File

@@ -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}</>
) : (

View 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"/);
});

View 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);
});

View 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;
}

View File

@@ -29,12 +29,20 @@ services:
retries: 10
server:
image: ${SERVER_IMAGE:?Set SERVER_IMAGE in .env.production}
build:
context: .
dockerfile: Dockerfile.server
args:
APP_VERSION: ${APP_VERSION:-unknown}
APP_BUILD_TIME: ${APP_BUILD_TIME:-}
APP_IMAGE_TAG: ${SERVER_IMAGE:-}
restart: unless-stopped
environment:
NODE_ENV: production
APP_VERSION: ${APP_VERSION:-unknown}
APP_BUILD_TIME: ${APP_BUILD_TIME:-}
APP_IMAGE_TAG: ${SERVER_IMAGE:-}
DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-ftb_pm}}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
@@ -55,7 +63,7 @@ services:
test:
[
'CMD-SHELL',
'node -e "fetch(''http://127.0.0.1:3001/api/v1/config/ai'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"',
'node -e "fetch(''http://127.0.0.1:3001/api/v1/health/version'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"',
]
interval: 15s
timeout: 5s
@@ -63,17 +71,24 @@ services:
start_period: 20s
web:
image: ${WEB_IMAGE:?Set WEB_IMAGE in .env.production}
build:
context: .
dockerfile: Dockerfile.web
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-/api/v1}
NEXT_API_PROXY_TARGET: ${NEXT_API_PROXY_TARGET:-http://server:3001}
APP_VERSION: ${APP_VERSION:-unknown}
APP_BUILD_TIME: ${APP_BUILD_TIME:-}
APP_IMAGE_TAG: ${WEB_IMAGE:-}
restart: unless-stopped
environment:
NODE_ENV: production
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-/api/v1}
NEXT_API_PROXY_TARGET: ${NEXT_API_PROXY_TARGET:-http://server:3001}
NEXT_PUBLIC_APP_VERSION: ${APP_VERSION:-unknown}
NEXT_PUBLIC_APP_BUILD_TIME: ${APP_BUILD_TIME:-}
NEXT_PUBLIC_APP_IMAGE_TAG: ${WEB_IMAGE:-}
NEXTAUTH_URL: ${NEXTAUTH_URL:-}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-}
depends_on:

View File

@@ -191,6 +191,14 @@ V2 接入后端后改为基于 `ProjectMember` 表的 RBACOwner/Admin/Member/
- `task-category.ts`DevTask/TestCase 共用任务类型字典,`id` 用于存储AI 输出的 `taskTypeName` 必须是可复用类型;可复用开发类型可在采纳时自动追加到字典,测试用例未知类型回退到已有测试分类,`code` 仅作可选语义映射。
页面组件只消费规则层输出,不直接拼完成条件或候选筛选条件。
## Production Runtime Version Layer (2026-07-06)
Production deployment now treats CI-built Docker images as the release artifact. `Dockerfile.web` and `Dockerfile.server` accept `APP_VERSION`, `APP_BUILD_TIME`, and image tag build metadata; `docker-compose.prod.yml` pulls immutable `WEB_IMAGE` and `SERVER_IMAGE` tags instead of relying on deployment-side rebuilds.
The server exposes `GET /api/v1/health/version`, returning the running server commit/version metadata. The web app embeds `NEXT_PUBLIC_APP_VERSION` at build time and shows a refresh prompt when the browser is still on an older frontend bundle than the server runtime.
GitHub Actions is the production release orchestrator: build and push images, SSH to the server, update `.env.production` image tags, pull images, run `pnpm --filter server db:deploy`, restart Compose services, and verify `/api/v1/health/version` against the current commit SHA.
## Work Activity Daily Report Layer (2026-06-26)
The personal daily report is derived from two inputs:

View File

@@ -477,6 +477,20 @@
**理由**Docker Compose 足够覆盖当前单机云服务器和本地服务器形态,部署成本低、可读性强,也符合现阶段 2 核 4G 云主机目标。同域反代能减少 CORS 和公网端口暴露面。本地服务器默认 8080避免占用 80 端口或要求管理员权限;云服务器继续使用 80 作为外层入口。HTTPS 证书自动续期和域名接入在不同云环境差异较大,先作为外层能力处理,避免把生产部署模板绑死在某一种证书方案上。
## 38.1. 生产发布改为 CI 镜像制品 + 运行版本校验
**问题**:仅让部署侧 `git pull` 最新代码并不能保证线上用户看到新前端。Next.js 前端需要重新构建Docker 容器也需要重新创建;如果对方只拉代码、不 `build/up`,就会出现仓库是新的、运行容器仍是旧的情况,需求池搜索、成员用户名、性能改动等都无法靠肉眼判断是否已经上线。
**决策**
- GitHub Actions 在 `master` 更新时构建 `web` / `server` Docker 镜像,并推送到 GHCR。
- 镜像以 commit SHA 作为不可变 tag同时更新 `master` tag。
- `docker-compose.prod.yml` 使用 `WEB_IMAGE` / `SERVER_IMAGE` 拉取镜像,保留 `build` 仅作为本地兜底。
- `APP_VERSION` 使用 commit SHA构建时写入前端 `NEXT_PUBLIC_APP_VERSION` 和后端运行环境。
- 后端提供 `GET /api/v1/health/version`Actions 发布结束后必须校验返回版本等于本次 commit SHA。
- 前端定时比较自身构建版本和服务端运行版本,不一致时提示刷新页面。
**理由**:发布物必须是 CI 产出的镜像,而不是服务器上的源码目录。版本号打进镜像后,部署问题可以被机器判断:如果 Actions 校验通过,说明线上容器已经运行本次提交;如果校验失败,问题就在部署链路而不是业务代码。前端刷新提示解决的是浏览器仍持有旧 bundle 的尾部问题,不能替代容器更新,但能让用户明确知道需要刷新。
## 39. AppData 文档写入采用乐观锁,先阻止静默覆盖
**问题**V2.1 阶段业务数据仍按模块存成 `app_data.value` 整份 JSON 文档。多人同时打开同一模块后,如果 A 和 B 都基于旧副本编辑,原来的无条件 `upsert` 会让后保存的人覆盖先保存的人尤其是任务、Bug、成员等高频写入数据。

View File

@@ -144,6 +144,45 @@ docker compose --env-file .env.production -f docker-compose.prod.yml ps
curl http://localhost/api/v1/config/ai
```
## 全自动生产发布(推荐)
生产环境推荐走 `.github/workflows/deploy-production.yml`,不再要求部署侧手工执行 `docker compose build` 或手工判断是否需要 `up`
GitHub 仓库需要配置这些 Secrets
- `PROD_HOST`:生产服务器地址。
- `PROD_USER`SSH 用户。
- `PROD_SSH_KEY`SSH 私钥。
- `PROD_APP_DIR`:服务器上的项目目录。
- `GHCR_READ_TOKEN`可选。GHCR 镜像为私有包时,用于服务器 `docker login ghcr.io` 拉镜像。
服务器首次准备仍然需要完成一次:
```bash
git clone <repo-url> ftb-project-management
cd ftb-project-management
cp .env.production.example .env.production
```
然后按实际环境改好 `.env.production` 里的数据库密码、域名、`NEXTAUTH_SECRET`、AI key 等。`APP_VERSION``APP_BUILD_TIME``WEB_IMAGE``SERVER_IMAGE` 会由 GitHub Actions 在每次发布时自动更新。
之后只要代码合并或 push 到 `master`Actions 会自动执行:
1. 用当前 commit SHA 构建 `web``server` Docker 镜像。
2. 推送镜像到 GHCR镜像 tag 同时包含 commit SHA 和 `master`
3. SSH 到生产服务器,`git pull --ff-only origin master` 更新 Compose 和部署脚本。
4. 写入 `.env.production``APP_VERSION``APP_BUILD_TIME``WEB_IMAGE``SERVER_IMAGE`
5. 执行 `docker compose --env-file .env.production -f docker-compose.prod.yml pull web server` 拉取本次 SHA 镜像。
6. 启动数据库与 Redis执行 `pnpm --filter server db:deploy`
7. 执行 `docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans` 重启服务。
8. 通过 `/api/v1/health/version` 校验运行中的后端版本是否等于本次 commit SHA。
如果最后一步失败Actions 会红掉,说明“代码已合并”不等于“线上容器已更新”。本地或服务器也可以手工运行:
```bash
pnpm deploy:check-runtime http://localhost/api/v1/health/version <expected-commit-sha>
```
## Nginx 路由
`deploy/nginx/default.conf.template` 使用官方 Nginx 镜像的模板机制生成配置:

View File

@@ -6,6 +6,11 @@ V2.3 在 V2.2 快读路径之后补上写入闭环:前端仍保留现有 AppDa
### 已完成(按时间倒序)
**2026-07-06**
- Added production CI/CD flow: GitHub Actions builds `web` and `server` Docker images, pushes immutable commit-SHA tags to GHCR, deploys by SSH, pulls images on the server, runs `pnpm --filter server db:deploy`, restarts Compose, and verifies `/api/v1/health/version`.
- Added runtime version metadata: backend `GET /api/v1/health/version`, Docker build args/env, and a frontend refresh banner when browser assets are older than the server runtime.
- Added `pnpm deploy:check-runtime` and expanded `pnpm deploy:verify` so deployment artifacts include workflow, image metadata, and runtime version checks.
**2026-07-03**
- V2.3 AppData write-side bridge added: successful `PUT /api/v1/data/:key` calls now trigger `AppDataV23SyncService` relation-table sync after optimistic-lock AppData writes.
- Relation sync reuses the V2.2 mapper and replaces current-state rows by partition scope: requirements by `product_id`, version plans/dev tasks/test cases/bugs by `version_id`.

View File

@@ -240,6 +240,20 @@ AI 估时约束:
- 产品方案和 UI 设计的引用需求不再用 checkbox 直接标记完成,必须通过 `requirementCoverage[]` 记录 `not_started / partial / completed`、本次已完成内容和剩余内容;只有 `completed` 计入成果提交门禁。
- 产品/UI 计划右侧展示计划日志,需求进度更新和 AI 拆解触发/完成/失败都写入 `VersionPlan.logs[]`,页面只消费日志数据,不临时拼历史。
- 调研/产品方案/UI 设计的计划级 `actualStartAt` 只表示计划容器已开始,不直接作为具体任务日报耗时。具体调研方向或引用需求需要先点击「开始任务」,写入当前行的 `currentWorkStartedAt`;提交「记录」时日志和 `work-activities` 保留 `workStartedAt`,日报耗时按 `workStartedAt -> 记录提交时间` 计算,提交后清空当前行的开始时间。完全完成可直接提交结束本次耗时,部分完成才需要填写本次已完成内容和剩余未完成内容。
## Production Release Workflow (2026-07-06)
Production releases use GitHub Actions as the default path. The deployment side should not manually rebuild frontend assets after every change; it keeps `.env.production` and Docker volumes, while Actions builds immutable images and updates the running containers.
Standard flow:
1. Merge or push to `master`.
2. GitHub Actions builds `web` and `server` images with `APP_VERSION=<commit-sha>`.
3. Actions pushes the images to GHCR.
4. Actions SSHes into the server, updates `.env.production` image tags, pulls the images, runs `pnpm --filter server db:deploy`, and restarts Compose.
5. Actions verifies `/api/v1/health/version` equals the commit SHA.
If a user reports "latest code pulled but UI is still old", first compare the running version endpoint with the expected commit. A mismatch means deployment did not update the running container. A match means the code is deployed and the remaining issue is likely browser cache, data, or business logic.
## Work Activity Daily Report Flow (2026-06-26)
The daily report flow uses mixed evidence:

View File

@@ -8,6 +8,7 @@
"type-check": "turbo type-check",
"test": "turbo test",
"deploy:verify": "node scripts/verify-production-deploy.mjs",
"deploy:check-runtime": "node scripts/check-runtime-version.mjs",
"deploy:local:build": "docker compose --env-file .env.local-server -f docker-compose.local.yml build",
"deploy:local:up": "docker compose --env-file .env.local-server -f docker-compose.local.yml up -d",
"deploy:local:down": "docker compose --env-file .env.local-server -f docker-compose.local.yml down",

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env node
const DEFAULT_URL = 'http://127.0.0.1/api/v1/health/version';
const url = process.argv[2] || process.env.RUNTIME_VERSION_URL || DEFAULT_URL;
const expectedVersion = process.argv[3] || process.env.APP_VERSION || process.env.GITHUB_SHA || '';
const attempts = Number.parseInt(process.env.RUNTIME_VERSION_ATTEMPTS || '30', 10);
const intervalMs = Number.parseInt(process.env.RUNTIME_VERSION_INTERVAL_MS || '2000', 10);
const timeoutMs = Number.parseInt(process.env.RUNTIME_VERSION_TIMEOUT_MS || '5000', 10);
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isVersionPayload(value) {
return (
value &&
typeof value === 'object' &&
value.service === 'server' &&
typeof value.version === 'string'
);
}
async function fetchVersion() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
if (!isVersionPayload(payload)) {
throw new Error(`Unexpected payload: ${JSON.stringify(payload)}`);
}
return payload;
} finally {
clearTimeout(timeout);
}
}
let lastError = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const payload = await fetchVersion();
if (expectedVersion && payload.version !== expectedVersion) {
throw new Error(`Expected ${expectedVersion}, got ${payload.version}`);
}
console.log(`Runtime version verified: ${payload.version}`);
process.exit(0);
} catch (error) {
lastError = error;
if (attempt < attempts) {
await sleep(intervalMs);
}
}
}
console.error(`Runtime version check failed for ${url}: ${lastError?.message || 'unknown error'}`);
process.exit(1);

View File

@@ -12,6 +12,8 @@ const checks = [
'pnpm --filter @ftb/shared build',
'pnpm --filter web build',
'NEXT_PUBLIC_API_URL',
'NEXT_PUBLIC_APP_VERSION',
'ARG APP_VERSION=unknown',
'CMD ["pnpm", "--filter", "web", "start"]',
],
},
@@ -22,6 +24,8 @@ const checks = [
'pnpm --filter @ftb/shared build',
'pnpm --filter server build',
'prisma generate',
'APP_VERSION',
'ARG APP_VERSION=unknown',
'CMD ["pnpm", "--filter", "server", "start:prod"]',
],
},
@@ -35,9 +39,26 @@ const checks = [
'nginx:',
'server_data:',
'NEXT_PUBLIC_API_URL',
'/api/v1/config/ai',
'SERVER_IMAGE',
'WEB_IMAGE',
'APP_VERSION',
'/api/v1/health/version',
],
},
{
file: '.github/workflows/deploy-production.yml',
snippets: [
'docker/build-push-action',
'appleboy/ssh-action',
'docker compose --env-file .env.production -f docker-compose.prod.yml pull',
'pnpm --filter server db:deploy',
'/api/v1/health/version',
],
},
{
file: 'scripts/check-runtime-version.mjs',
snippets: ['Runtime version verified', '/api/v1/health/version', 'expectedVersion'],
},
{
file: 'docker-compose.local.yml',
snippets: [
@@ -66,6 +87,9 @@ const checks = [
'POSTGRES_PASSWORD=',
'DATABASE_URL=postgresql://',
'NEXT_PUBLIC_API_URL=',
'APP_VERSION=',
'WEB_IMAGE=',
'SERVER_IMAGE=',
'NEXTAUTH_SECRET=',
'ANTHROPIC_API_KEY=',
],