diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml index 0414690..c6c905a 100644 --- a/.github/workflows/deploy-tencent.yml +++ b/.github/workflows/deploy-tencent.yml @@ -1,9 +1,19 @@ -name: Deploy Tencent +name: Deploy Tencent Docker permissions: contents: read on: + pull_request: + paths: + - apps/web/.dockerignore + - apps/web/Dockerfile + - apps/web/next.config.ts + - apps/web/app/api/health/** + - apps/web/package.json + - apps/web/package-lock.json + - deploy/** + - .github/workflows/deploy-tencent.yml push: branches: - main @@ -11,68 +21,116 @@ on: workflow_dispatch: concurrency: - group: deploy-tencent-${{ github.ref }} + group: deploy-tencent-docker-${{ github.ref }} cancel-in-progress: false +env: + IMAGE_NAME: meteortest-web + jobs: - deploy: - name: Deploy Web - runs-on: [self-hosted, linux, x64, tencent, meteortest] + build: + name: Build Web image + runs-on: ubuntu-latest environment: tencent steps: - name: Checkout uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: apps/web + file: apps/web/Dockerfile + platforms: linux/amd64 + outputs: type=docker,dest=${{ runner.temp }}/meteortest-image.tar + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Compress image artifact + if: github.event_name != 'pull_request' + run: gzip -1 < "${RUNNER_TEMP}/meteortest-image.tar" > deploy/meteortest-image.tar.gz + + - name: Upload image artifact to Tencent + if: github.event_name != 'pull_request' + env: + DEPLOY_KEY: ${{ secrets.TENCENT_ARTIFACT_SSH_KEY }} + DEPLOY_KNOWN_HOSTS: ${{ secrets.TENCENT_ARTIFACT_KNOWN_HOSTS }} + DEPLOY_HOST: ${{ vars.TENCENT_ARTIFACT_HOST }} + DEPLOY_USER: ${{ vars.TENCENT_ARTIFACT_USER }} + run: | + key_file="${RUNNER_TEMP}/artifact-upload-key" + known_hosts_file="${RUNNER_TEMP}/artifact-known-hosts" + printf '%s\n' "${DEPLOY_KEY}" > "${key_file}" + printf '%s\n' "${DEPLOY_KNOWN_HOSTS}" > "${known_hosts_file}" + chmod 0600 "${key_file}" "${known_hosts_file}" + ssh -i "${key_file}" -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="${known_hosts_file}" \ + "${DEPLOY_USER}@${DEPLOY_HOST}" "${GITHUB_SHA}" \ + < deploy/meteortest-image.tar.gz + rm -f deploy/meteortest-image.tar.gz "${key_file}" "${known_hosts_file}" + + - name: Upload deployment manifest + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: meteortest-deployment-${{ github.sha }} + path: | + deploy/docker-compose.yml + deploy/deploy-container.sh + retention-days: 7 + + deploy: + name: Deploy Web container + if: github.event_name != 'pull_request' + needs: build + runs-on: [self-hosted, linux, x64, tencent, meteortest] + environment: tencent + steps: + - name: Download deployment manifest + uses: actions/download-artifact@v4 + with: + name: meteortest-deployment-${{ github.sha }} + path: deployment + - name: Resolve target id: target shell: bash run: | if [ "${GITHUB_REF_NAME}" = "release" ]; then - echo "app_dir=/srv/meteortest-release" >> "$GITHUB_OUTPUT" - echo "pm2_name=meteortest-release" >> "$GITHUB_OUTPUT" + echo "environment=production" >> "$GITHUB_OUTPUT" echo "port=3200" >> "$GITHUB_OUTPUT" + echo "shadow_port=3210" >> "$GITHUB_OUTPUT" + echo "pm2_name=meteortest-release" >> "$GITHUB_OUTPUT" + echo "public_url=https://meteortest.jcmeteor.com/" >> "$GITHUB_OUTPUT" else - echo "app_dir=/srv/meteortest" >> "$GITHUB_OUTPUT" - echo "pm2_name=meteortest-web" >> "$GITHUB_OUTPUT" + echo "environment=preview" >> "$GITHUB_OUTPUT" echo "port=3201" >> "$GITHUB_OUTPUT" + echo "shadow_port=3211" >> "$GITHUB_OUTPUT" + echo "pm2_name=meteortest-web" >> "$GITHUB_OUTPUT" + echo "public_url=https://mt-pre.jcmeteor.com/" >> "$GITHUB_OUTPUT" fi + - name: Load image artifact + run: gzip -dc "/srv/deploy-inbox/meteortest/${GITHUB_SHA}.tar.gz" | docker load + - name: Deploy - shell: bash + env: + APP_NAME: meteortest + ENVIRONMENT: ${{ steps.target.outputs.environment }} + HOST_PORT: ${{ steps.target.outputs.port }} + IMAGE_URI: meteortest-web:${{ github.sha }} + PM2_NAME: ${{ steps.target.outputs.pm2_name }} + PUBLIC_URL: ${{ steps.target.outputs.public_url }} + RUNTIME_ENV_FILE: /etc/meteortest/meteortest-web.env + SHADOW_PORT: ${{ steps.target.outputs.shadow_port }} run: | - retry() { - local attempts="$1" - local delay="$2" - shift 2 - - local attempt=1 - until "$@"; do - if [ "$attempt" -ge "$attempts" ]; then - return 1 - fi - - echo "Command failed. Retrying in ${delay}s (${attempt}/${attempts})..." - sleep "$delay" - attempt=$((attempt + 1)) - done - } - - mkdir -p '${{ steps.target.outputs.app_dir }}' - rsync -a --delete \ - --exclude '.git' \ - --exclude 'apps/web/.next' \ - --exclude 'apps/web/node_modules' \ - ./ '${{ steps.target.outputs.app_dir }}/' - - cd '${{ steps.target.outputs.app_dir }}/apps/web' - retry 3 15 npm ci - set -a - . /etc/meteortest/meteortest-web.env - set +a - retry 2 15 npm run build + chmod +x deployment/deploy-container.sh + deployment/deploy-container.sh - pm2 delete '${{ steps.target.outputs.pm2_name }}' || true - unset RUNNER_TRACKING_ID - pm2 start npm --name '${{ steps.target.outputs.pm2_name }}' -- run start -- --hostname 127.0.0.1 --port '${{ steps.target.outputs.port }}' - pm2 save - retry 12 5 curl -fsS 'http://127.0.0.1:${{ steps.target.outputs.port }}/' >/dev/null + - name: Remove uploaded image artifact + if: success() + run: rm -f "/srv/deploy-inbox/meteortest/${GITHUB_SHA}.tar.gz" diff --git a/README.md b/README.md index 82bbb01..fe084ca 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ main -> mt-pre.jcmeteor.com release -> meteortest.jcmeteor.com ``` -The detailed Docker/TCR target architecture, runner, branch, environment, port mapping, PM2 migration, and rollback flow lives in `docs/tencent-docker-deployment.md`. +The detailed Docker image artifact architecture, runner, branch, environment, port mapping, PM2 migration, and rollback flow lives in `docs/tencent-docker-deployment.md`. Production publishing is automated by the GitHub Actions `Release Manager` workflow: diff --git a/README.zh-CN.md b/README.zh-CN.md index 81a1918..c818100 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -251,7 +251,7 @@ main -> mt-pre.jcmeteor.com release -> meteortest.jcmeteor.com ``` -Docker/TCR 目标架构、runner、分支、环境变量、端口映射、PM2 迁移和回滚流程详见 `docs/tencent-docker-deployment.zh-CN.md`。 +Docker/Actions Artifact 架构、runner、分支、环境变量、端口映射、PM2 迁移和回滚流程详见 `docs/tencent-docker-deployment.zh-CN.md`。 生产发布通过 GitHub Actions 的 `Release Manager` workflow 自动化: diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..e9dc615 --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,10 @@ +.git +.next +node_modules +.env +.env.* +!.env.example +*.log +coverage +playwright-report +test-results diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..1813eb2 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,32 @@ +FROM node:24-bookworm-slim AS dependencies + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM dependencies AS builder + +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:24-bookworm-slim AS runtime + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + HOSTNAME=0.0.0.0 \ + PORT=3000 + +WORKDIR /app + +RUN groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid nodejs nextjs + +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public + +USER nextjs +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/apps/web/app/api/health/route.ts b/apps/web/app/api/health/route.ts new file mode 100644 index 0000000..76f9209 --- /dev/null +++ b/apps/web/app/api/health/route.ts @@ -0,0 +1,8 @@ +/** + * Process-local health endpoint used by Docker and deployment checks. + */ +import { NextResponse } from 'next/server' + +export function GET() { + return NextResponse.json({ service: 'meteortest-web', status: 'ok' }) +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 6b931f9..820268b 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { devIndicators: false, + output: "standalone", }; if (process.env.VERCEL === "1") { diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 30dc6e0..8d76c53 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { createServerClient } from '@supabase/ssr' -const publicPaths = new Set(['/login', '/api/auth/logout', '/favicon.ico']) +const publicPaths = new Set(['/login', '/api/auth/logout', '/api/health', '/favicon.ico']) const authOptionalPaths = new Set(['/api/agent/status']) async function getSessionResponse(request: NextRequest) { diff --git a/deploy/deploy-container.sh b/deploy/deploy-container.sh new file mode 100644 index 0000000..defb212 --- /dev/null +++ b/deploy/deploy-container.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +required=(APP_NAME ENVIRONMENT IMAGE_URI HOST_PORT SHADOW_PORT RUNTIME_ENV_FILE PM2_NAME PUBLIC_URL) +for name in "${required[@]}"; do + if [ -z "${!name:-}" ]; then + echo "Missing required environment variable: ${name}" >&2 + exit 1 + fi +done + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +deploy_dir="/srv/containers/${APP_NAME}/${ENVIRONMENT}" +compose_file="${deploy_dir}/docker-compose.yml" +deployment_env="${deploy_dir}/deployment.env" +project_name="${APP_NAME}-${ENVIRONMENT}" + +mkdir -p "${deploy_dir}" +install -m 0644 "${script_dir}/docker-compose.yml" "${compose_file}" + +write_deployment_env() { + local file="$1" + local image="$2" + local port="$3" + { + printf 'IMAGE_URI=%s\n' "${image}" + printf 'HOST_PORT=%s\n' "${port}" + printf 'RUNTIME_ENV_FILE=%s\n' "${RUNTIME_ENV_FILE}" + } > "${file}" + chmod 0600 "${file}" +} + +compose() { + local project="$1" + local env_file="$2" + shift 2 + docker compose --project-name "${project}" --env-file "${env_file}" --file "${compose_file}" "$@" +} + +wait_for_health() { + local port="$1" + local public_url="${2:-}" + local attempt + for attempt in $(seq 1 24); do + if curl -fsS --max-time 5 "http://127.0.0.1:${port}/api/health" >/dev/null; then + if [ -z "${public_url}" ] || curl -fsS --max-time 10 "${public_url}" >/dev/null; then + return 0 + fi + fi + sleep 5 + done + return 1 +} + +current_container="$(compose "${project_name}" "${deployment_env}" ps -q web 2>/dev/null || true)" +previous_image="" +if [ -n "${current_container}" ]; then + previous_image="$(docker inspect --format '{{.Config.Image}}' "${current_container}")" +fi + +if ! docker image inspect "${IMAGE_URI}" >/dev/null 2>&1; then + echo "Image is not loaded: ${IMAGE_URI}" >&2 + exit 1 +fi + +if [ -z "${current_container}" ] && pm2 describe "${PM2_NAME}" >/dev/null 2>&1; then + shadow_env="$(mktemp)" + shadow_project="${project_name}-shadow" + write_deployment_env "${shadow_env}" "${IMAGE_URI}" "${SHADOW_PORT}" + trap 'compose "${shadow_project}" "${shadow_env}" down --remove-orphans >/dev/null 2>&1 || true; rm -f "${shadow_env}"' EXIT + + compose "${shadow_project}" "${shadow_env}" up -d --wait --wait-timeout 180 + wait_for_health "${SHADOW_PORT}" + + pm2 stop "${PM2_NAME}" + write_deployment_env "${deployment_env}" "${IMAGE_URI}" "${HOST_PORT}" + if ! compose "${project_name}" "${deployment_env}" up -d --wait --wait-timeout 180 \ + || ! wait_for_health "${HOST_PORT}" "${PUBLIC_URL}"; then + compose "${project_name}" "${deployment_env}" down --remove-orphans || true + pm2 restart "${PM2_NAME}" --update-env + wait_for_health "${HOST_PORT}" "${PUBLIC_URL}" + exit 1 + fi + + compose "${shadow_project}" "${shadow_env}" down --remove-orphans + rm -f "${shadow_env}" + trap - EXIT + pm2 save +else + write_deployment_env "${deployment_env}" "${IMAGE_URI}" "${HOST_PORT}" + if ! compose "${project_name}" "${deployment_env}" up -d --wait --wait-timeout 180 \ + || ! wait_for_health "${HOST_PORT}" "${PUBLIC_URL}"; then + if [ -n "${previous_image}" ]; then + echo "Deployment failed; restoring ${previous_image}" >&2 + write_deployment_env "${deployment_env}" "${previous_image}" "${HOST_PORT}" + compose "${project_name}" "${deployment_env}" up -d --wait --wait-timeout 180 + wait_for_health "${HOST_PORT}" "${PUBLIC_URL}" + fi + exit 1 + fi +fi + +printf '%s\n' "${IMAGE_URI}" > "${deploy_dir}/current-image" +printf 'Deployed %s to %s (%s)\n' "${IMAGE_URI}" "${project_name}" "${PUBLIC_URL}" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..c963897 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,29 @@ +services: + web: + image: ${IMAGE_URI:?IMAGE_URI is required} + init: true + restart: unless-stopped + env_file: + - ${RUNTIME_ENV_FILE:?RUNTIME_ENV_FILE is required} + environment: + HOSTNAME: 0.0.0.0 + NODE_ENV: production + PORT: 3000 + ports: + - 127.0.0.1:${HOST_PORT:?HOST_PORT is required}:3000 + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + mem_limit: 768m + logging: + driver: json-file + options: + max-size: 10m + max-file: "3" diff --git a/deploy/receive-image-upload.sh b/deploy/receive-image-upload.sh new file mode 100644 index 0000000..c6f03c6 --- /dev/null +++ b/deploy/receive-image-upload.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +app_name="${1:-}" +image_sha="${SSH_ORIGINAL_COMMAND:-}" + +if [[ ! "${app_name}" =~ ^(meteorvoice|meteortest)$ ]]; then + echo "Unsupported application" >&2 + exit 1 +fi + +if [[ ! "${image_sha}" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid image SHA" >&2 + exit 1 +fi + +target_dir="/srv/deploy-inbox/${app_name}" +target_file="${target_dir}/${image_sha}.tar.gz" +temporary_file="${target_file}.uploading" + +mkdir -p "${target_dir}" +umask 0027 +ulimit -f 262144 +timeout 900 tee "${temporary_file}" >/dev/null +test -s "${temporary_file}" +mv "${temporary_file}" "${target_file}" +printf 'Stored %s\n' "${target_file}" diff --git a/docs/README.md b/docs/README.md index a86f9fc..cf2cfd5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,7 @@ This is the single entry point for MeteorTest documentation. Start here for new | Supabase runbook | `supabase-account-data-runbook.md` / `supabase-account-data-runbook.zh-CN.md` | Auth/RLS, preferences, AI history, display refs SQL execution and verification. | | Local Agent operations | `local-agent-operations.md` / `local-agent-operations.zh-CN.md` | Agent daemon, check interval, heartbeat, logs, OpenClaw checks. | | Public preview deployment | `vercel-public-preview.md` / `vercel-public-preview.zh-CN.md` | Vercel public preview deployment and safety checks. | -| Tencent Docker deployment | `tencent-docker-deployment.md` / `tencent-docker-deployment.zh-CN.md` | Docker/TCR target architecture, main/release mapping, PM2 migration, and rollback. | +| Tencent Docker deployment | `tencent-docker-deployment.md` / `tencent-docker-deployment.zh-CN.md` | Docker image artifact architecture, main/release mapping, PM2 migration, and rollback. | | Release automation | `release-manager.md` | GitHub Actions release workflow, release PR automation, and recovery commands. | | Private Agent loop | `private-agent-preview-loop.md` / `private-agent-preview-loop.zh-CN.md` | Validation flow for public Web plus private Agent execution. | | Data exposure boundary | `internal-id-exposure-hardening.md` / `internal-id-exposure-hardening.zh-CN.md` | Internal UUIDs, public refs, DTO/View Model rules. | diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 7d54f5d..f11ba54 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -23,7 +23,7 @@ | Supabase 手册 | `supabase-account-data-runbook.zh-CN.md` / `supabase-account-data-runbook.md` | Auth/RLS、账号偏好、AI 历史、display refs 的 SQL 执行与验证。 | | Local Agent 运维 | `local-agent-operations.zh-CN.md` / `local-agent-operations.md` | Agent 常驻、检查频率、心跳、日志、OpenClaw 巡检。 | | 公网预览部署 | `vercel-public-preview.zh-CN.md` / `vercel-public-preview.md` | Vercel 公网预览部署和安全检查。 | -| 腾讯云 Docker 部署 | `tencent-docker-deployment.zh-CN.md` / `tencent-docker-deployment.md` | Docker/TCR 目标架构、main/release 映射、PM2 迁移和回滚。 | +| 腾讯云 Docker 部署 | `tencent-docker-deployment.zh-CN.md` / `tencent-docker-deployment.md` | Docker 镜像制品架构、main/release 映射、PM2 迁移和回滚。 | | 发布自动化 | `release-manager.md` | GitHub Actions 发布入口、release PR 自动化和中断恢复命令。 | | 私有 Agent 闭环 | `private-agent-preview-loop.zh-CN.md` / `private-agent-preview-loop.md` | 私有 Agent 连接公网 Web 后端的验证流程。 | | 数据暴露边界 | `internal-id-exposure-hardening.zh-CN.md` / `internal-id-exposure-hardening.md` | 内部 UUID、公开引用、DTO/View Model 规则。 | diff --git a/docs/tencent-docker-deployment.md b/docs/tencent-docker-deployment.md index 0c6ccf7..9fc44c3 100644 --- a/docs/tencent-docker-deployment.md +++ b/docs/tencent-docker-deployment.md @@ -17,9 +17,9 @@ Nginx MUST continue binding public ports 80/443. Containers MUST publish only to 1. A GitHub-hosted runner checks out the requested commit. 2. CI installs dependencies in `apps/web`, then runs lint and the production build. Repository-wide validation continues to cover the Python Agent separately. -3. CI builds a multi-stage Next.js standalone image and pushes it to Tencent Container Registry (TCR). +3. CI exports the multi-stage Next.js standalone image as a compressed Docker image artifact. 4. The image is tagged with an immutable commit SHA. Branch and release tags MAY be aliases, but deployment MUST resolve to the SHA tag. -5. The MeteorTest Tencent runner pulls the image and updates only the matching Compose project. +5. The MeteorTest Tencent runner loads the uploaded artifact into Docker and updates only the matching Compose project. 6. The runner waits for container health and verifies the public domain. 7. A failed health check MUST restore the previous image SHA. @@ -27,7 +27,7 @@ The server MUST NOT copy source, install npm dependencies, or build Next.js afte ## Image contract -- Proposed image: `//meteortest-web`. +- Image tag: `meteortest-web:`. - Build context: `apps/web` unless implementation validation identifies a repository-root dependency. - Next.js MUST use `output: 'standalone'`. - The runtime stage MUST contain only the standalone server, static assets, and required public files. @@ -46,11 +46,7 @@ Docker migration covers only `apps/web`. The private Python Local Agent MUST rem ## Configuration and secrets -Real Web credentials remain in `/etc/meteortest/meteortest-web.env`. Compose injects the file at container startup. GitHub stores only TCR access and non-secret deployment metadata. - -- variables: TCR registry, namespace, image repository; -- secrets: TCR username/token or equivalent short-lived credential; -- Supabase service-role, AI provider, Agent, and project execution secrets MUST NOT enter the image. +Real Web credentials remain in `/etc/meteortest/meteortest-web.env`. Compose injects the file at container startup. The GitHub-hosted runner sends the compressed image directly to a Tencent inbox through an SSH key restricted to that upload command. The self-hosted runner only loads and deploys it. No container-registry password or interactive upload shell is required. Supabase service-role, AI provider, Agent, and project execution secrets MUST NOT enter the image artifact. ## Compose requirements @@ -83,8 +79,8 @@ Do not run `pm2 kill`. Keep PM2 definitions and source directories until both en 1. resolve the immutable image SHA; 2. record the currently running SHA; -3. authenticate to TCR and pull the image; -4. update the matching Compose project; +3. download and load the image artifact; +4. update the matching Compose project from the local immutable image; 5. wait for container health; 6. verify the localhost port and public domain; 7. retain the previous SHA for rollback. @@ -96,7 +92,7 @@ For a normal rollback, deploy the previous image SHA and repeat health checks. D ## Acceptance checklist - CI builds the same commit that is tagged and deployed. -- No application or Agent secret exists in image history, build logs, or TCR metadata. +- No application or Agent secret exists in image history, build logs, or artifact metadata. - Preview and production deploy and roll back independently. - Containers bind only to localhost. - Nginx passes `nginx -t` before reload. diff --git a/docs/tencent-docker-deployment.zh-CN.md b/docs/tencent-docker-deployment.zh-CN.md index e59ed73..07c744b 100644 --- a/docs/tencent-docker-deployment.zh-CN.md +++ b/docs/tencent-docker-deployment.zh-CN.md @@ -17,9 +17,9 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. 1. GitHub 托管 Runner 检出目标提交。 2. CI 在 `apps/web` 安装依赖并执行 lint 和生产构建;仓库级验证继续单独覆盖 Python Agent。 -3. CI 构建多阶段 Next.js standalone 镜像并推送到腾讯云容器镜像服务(TCR)。 +3. CI 将多阶段 Next.js standalone 镜像导出为压缩的 Docker 镜像制品。 4. 镜像使用不可变 commit SHA 标签;分支和版本标签 MAY 作为别名,但部署 MUST 最终解析到 SHA 标签。 -5. MeteorTest 专属腾讯 Runner 拉取镜像,只更新对应 Compose 项目。 +5. MeteorTest 专属腾讯 Runner 下载 Artifact、将不可变镜像加载到 Docker,并只更新对应 Compose 项目。 6. Runner 等待容器健康并验证公网域名。 7. 健康检查失败时 MUST 恢复上一镜像 SHA。 @@ -27,7 +27,7 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. ## 镜像契约 -- 建议镜像名:`//meteortest-web`。 +- 镜像标签:`meteortest-web:`。 - 构建上下文:默认使用 `apps/web`;如果实施验证发现仓库根依赖,再调整到根目录。 - Next.js MUST 使用 `output: 'standalone'`。 - 运行阶段 MUST 只包含 standalone server、静态资源和必要 public 文件。 @@ -46,11 +46,7 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. ## 配置与密钥 -真实 Web 凭据继续保存在 `/etc/meteortest/meteortest-web.env`,由 Compose 在容器启动时注入。GitHub 只保存 TCR 访问凭据和非敏感部署元数据。 - -- Variables:TCR registry、namespace、image repository; -- Secrets:TCR 用户名/Token 或等价短期凭据; -- Supabase service-role、AI provider、Agent 和项目执行密钥 MUST NOT 进入镜像。 +真实 Web 凭据继续保存在 `/etc/meteortest/meteortest-web.env`,由 Compose 在容器启动时注入。GitHub 托管 Runner 使用只允许写入指定目录的 SSH 密钥,将压缩镜像直传到腾讯云制品收件箱;自托管 Runner 只负责加载和部署。该通道不需要镜像仓库密码,上传账号也不能获得交互式 Shell。Supabase service-role、AI provider、Agent 和项目执行密钥 MUST NOT 进入镜像制品。 ## Compose 要求 @@ -83,8 +79,8 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. 1. 解析新的不可变镜像 SHA; 2. 记录当前运行 SHA; -3. 登录 TCR 并拉取镜像; -4. 更新对应 Compose 项目; +3. 下载并加载镜像 Artifact; +4. 使用本地不可变镜像更新对应 Compose 项目; 5. 等待容器健康; 6. 验证 localhost 端口和公网域名; 7. 保留上一 SHA 用于回滚。 @@ -96,7 +92,7 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. ## 验收清单 - CI 构建、标记和部署的是同一个 commit。 -- 镜像历史、构建日志和 TCR 元数据中不存在应用或 Agent 密钥。 +- 镜像历史、构建日志和 Artifact 元数据中不存在应用或 Agent 密钥。 - 预览与生产可独立部署、独立回滚。 - 容器只绑定 localhost。 - reload 前 Nginx 配置通过 `nginx -t`。