diff --git a/.env.production.example b/.env.production.example index 0454f7d..d8c423f 100644 --- a/.env.production.example +++ b/.env.production.example @@ -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 diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml new file mode 100644 index 0000000..9fa0e97 --- /dev/null +++ b/.github/workflows/deploy-production.yml @@ -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 diff --git a/Dockerfile.server b/Dockerfile.server index 15f4c5b..3295f15 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -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 diff --git a/Dockerfile.web b/Dockerfile.web index be7c0de..fe59920 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -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 diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 63b407e..f8f95ef 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -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: [ { diff --git a/apps/server/src/modules/health/health.controller.spec.ts b/apps/server/src/modules/health/health.controller.spec.ts new file mode 100644 index 0000000..f7d2a3b --- /dev/null +++ b/apps/server/src/modules/health/health.controller.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/modules/health/health.controller.ts b/apps/server/src/modules/health/health.controller.ts new file mode 100644 index 0000000..f199df0 --- /dev/null +++ b/apps/server/src/modules/health/health.controller.ts @@ -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(); + } +} diff --git a/apps/server/src/modules/health/health.module.ts b/apps/server/src/modules/health/health.module.ts new file mode 100644 index 0000000..79af239 --- /dev/null +++ b/apps/server/src/modules/health/health.module.ts @@ -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 {} diff --git a/apps/server/src/modules/health/health.service.spec.ts b/apps/server/src/modules/health/health.service.spec.ts new file mode 100644 index 0000000..789ec09 --- /dev/null +++ b/apps/server/src/modules/health/health.service.spec.ts @@ -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: '', + }); + }); +}); diff --git a/apps/server/src/modules/health/health.service.ts b/apps/server/src/modules/health/health.service.ts new file mode 100644 index 0000000..9a05359 --- /dev/null +++ b/apps/server/src/modules/health/health.service.ts @@ -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 || '', + }; + } +} diff --git a/apps/web/components/RuntimeVersionBanner.tsx b/apps/web/components/RuntimeVersionBanner.tsx new file mode 100644 index 0000000..c4287e6 --- /dev/null +++ b/apps/web/components/RuntimeVersionBanner.tsx @@ -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(null); + const [dismissedVersion, setDismissedVersion] = useState(''); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | undefined; + + const load = async () => { + try { + const next = await api.get('/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 ( +
+
+ +
+

发现新版本

+

+ 服务器已更新到 {serverVersion.version.slice(0, 7)},刷新后可使用最新前端。 +

+ +
+ +
+
+ ); +} diff --git a/apps/web/components/layout/LayoutShell.tsx b/apps/web/components/layout/LayoutShell.tsx index 748becc..fdde0c5 100644 --- a/apps/web/components/layout/LayoutShell.tsx +++ b/apps/web/components/layout/LayoutShell.tsx @@ -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 ( + {isLoginPage ? ( <>{children} ) : ( diff --git a/apps/web/lib/production-deploy-source.test.ts b/apps/web/lib/production-deploy-source.test.ts new file mode 100644 index 0000000..868ed74 --- /dev/null +++ b/apps/web/lib/production-deploy-source.test.ts @@ -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"/); +}); diff --git a/apps/web/lib/runtime-version.test.ts b/apps/web/lib/runtime-version.test.ts new file mode 100644 index 0000000..9740798 --- /dev/null +++ b/apps/web/lib/runtime-version.test.ts @@ -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); +}); diff --git a/apps/web/lib/runtime-version.ts b/apps/web/lib/runtime-version.ts new file mode 100644 index 0000000..6944aa5 --- /dev/null +++ b/apps/web/lib/runtime-version.ts @@ -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; +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c56c2fd..3f85e01 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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: diff --git a/docs/architecture.md b/docs/architecture.md index 171cbe3..015cbb6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -191,6 +191,14 @@ V2 接入后端后改为基于 `ProjectMember` 表的 RBAC(Owner/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: diff --git a/docs/decisions.md b/docs/decisions.md index 5cbe290..84f349c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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、成员等高频写入数据。 diff --git a/docs/deployment.md b/docs/deployment.md index c1f583f..0b75669 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 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 +``` + ## Nginx 路由 `deploy/nginx/default.conf.template` 使用官方 Nginx 镜像的模板机制生成配置: diff --git a/docs/roadmap.md b/docs/roadmap.md index 4d8459a..35ab4d8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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`. diff --git a/docs/workflow.md b/docs/workflow.md index ca7b016..293647f 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -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=`. +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: diff --git a/package.json b/package.json index 60544ce..0cd5de7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-runtime-version.mjs b/scripts/check-runtime-version.mjs new file mode 100644 index 0000000..ac25cc6 --- /dev/null +++ b/scripts/check-runtime-version.mjs @@ -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); diff --git a/scripts/verify-production-deploy.mjs b/scripts/verify-production-deploy.mjs index 00b34e3..3579b9a 100644 --- a/scripts/verify-production-deploy.mjs +++ b/scripts/verify-production-deploy.mjs @@ -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=', ],