diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml index 0414690..02db889 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,96 @@ 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: + build: + name: Validate deployment image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + if: github.event_name == 'pull_request' + uses: docker/build-push-action@v6 + with: + context: apps/web + file: apps/web/Dockerfile + platforms: linux/amd64 + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Upload validated source artifact + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: meteortest-source-${{ github.sha }} + path: | + apps/web + deploy/docker-compose.yml + deploy/deploy-container.sh + !apps/web/node_modules + !apps/web/.next + retention-days: 7 + deploy: - name: Deploy Web + name: Deploy Web container + if: github.event_name != 'pull_request' + needs: build runs-on: [self-hosted, linux, x64, tencent, meteortest] environment: tencent steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Download validated source artifact + uses: actions/download-artifact@v4 + with: + name: meteortest-source-${{ github.sha }} + path: source - 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: Build image from artifact + run: >- + docker build + --build-arg NPM_REGISTRY=https://registry.npmmirror.com + --tag "meteortest-web:${GITHUB_SHA}" + --file source/apps/web/Dockerfile + source/apps/web + - 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 - - 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 + chmod +x source/deploy/deploy-container.sh + source/deploy/deploy-container.sh diff --git a/.github/workflows/release-manager.yml b/.github/workflows/release-manager.yml new file mode 100644 index 0000000..003bb00 --- /dev/null +++ b/.github/workflows/release-manager.yml @@ -0,0 +1,51 @@ +name: Release Manager + +permissions: + contents: write + issues: write + pull-requests: write + actions: read + checks: read + +on: + workflow_dispatch: + inputs: + version: + description: Release version, for example 0.1.3 + required: true + type: string + action: + description: Release action + required: true + default: full + type: choice + options: + - full + - prepare + - promote + - verify + +concurrency: + group: release-manager + cancel-in-progress: false + +jobs: + release: + name: Release Manager + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_MANAGER_TOKEN || github.token }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Run release manager + env: + GH_TOKEN: ${{ secrets.RELEASE_MANAGER_TOKEN || github.token }} + run: node scripts/release-manager.mjs "${{ inputs.action }}" --version "${{ inputs.version }}" diff --git a/AGENTS.md b/AGENTS.md index 3a3b2c7..c3f2339 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -287,7 +287,7 @@ Release policy: - `release` is the Tencent production deployment branch. - Keep feature work flowing into `main` first, then promote `main` to `release` through a PR after validation. - Do not cherry-pick selectively into `release` unless the user explicitly requests an emergency hotfix; sync any hotfix back to `main`. -- Tencent deployment mapping is documented in `docs/tencent-release-deployment.md`. +- Tencent Docker deployment mapping and PM2 migration are documented in `docs/tencent-docker-deployment.md`. Branch names should use: diff --git a/PROGRESS.md b/PROGRESS.md index 0c562d8..414c84a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -12,7 +12,7 @@ - Supabase 执行手册:`docs/supabase-account-data-runbook.zh-CN.md` - Local Agent 运维:`docs/local-agent-operations.zh-CN.md` - 公网预览部署:`docs/vercel-public-preview.zh-CN.md` -- 腾讯云 Release 部署:`docs/tencent-release-deployment.zh-CN.md` +- 腾讯云 Docker 部署:`docs/tencent-docker-deployment.zh-CN.md` - 私有 Agent 闭环验证:`docs/private-agent-preview-loop.zh-CN.md` - 内部 ID / DTO 边界:`docs/internal-id-exposure-hardening.zh-CN.md` diff --git a/README.md b/README.md index 6b53bd1..5e77394 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,15 @@ main -> mt-pre.jcmeteor.com release -> meteortest.jcmeteor.com ``` -The detailed runner, branch, ruleset, environment, and port mapping lives in `docs/tencent-release-deployment.md`. +The detailed Docker/source 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: + +```text +GitHub -> Actions -> Release Manager -> Run workflow +``` + +Use `action=full` with a semantic version such as `0.1.3`. The workflow prepares release files, promotes `main` to `release`, waits for Tencent deployment, verifies public URLs, and creates the GitHub Release. Detailed operation and recovery steps live in `docs/release-manager.md`. ## Recommended Validation Flow diff --git a/README.zh-CN.md b/README.zh-CN.md index 594f76e..c818100 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -251,7 +251,15 @@ main -> mt-pre.jcmeteor.com release -> meteortest.jcmeteor.com ``` -runner、分支、ruleset、环境变量和端口映射详见 `docs/tencent-release-deployment.zh-CN.md`。 +Docker/Actions Artifact 架构、runner、分支、环境变量、端口映射、PM2 迁移和回滚流程详见 `docs/tencent-docker-deployment.zh-CN.md`。 + +生产发布通过 GitHub Actions 的 `Release Manager` workflow 自动化: + +```text +GitHub -> Actions -> Release Manager -> Run workflow +``` + +选择 `action=full`,输入类似 `0.1.3` 的语义化版本号。workflow 会准备 release 文件、将 `main` 提升到 `release`、等待腾讯云部署、验证公网 URL,并创建 GitHub Release。详细操作和中断恢复步骤见 `docs/release-manager.md`。 ## 推荐验证流程 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..df77c9d --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,33 @@ +FROM node:24-bookworm-slim AS dependencies + +WORKDIR /app +ARG NPM_REGISTRY=https://registry.npmjs.org +COPY package.json package-lock.json ./ +RUN npm ci --registry="${NPM_REGISTRY}" + +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/package-lock.json b/apps/web/package-lock.json index dd2ea5f..57b4c63 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "meteortest-web", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meteortest-web", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@supabase/ssr": "^0.10.2", "@supabase/supabase-js": "^2.105.1", diff --git a/apps/web/package.json b/apps/web/package.json index 618ce9d..75d513a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "meteortest-web", - "version": "0.1.2", + "version": "0.1.3", "private": true, "scripts": { "dev": "next dev", 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..212f0c2 --- /dev/null +++ b/deploy/deploy-container.sh @@ -0,0 +1,105 @@ +#!/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 + env -u IMAGE_URI -u HOST_PORT -u RUNTIME_ENV_FILE \ + 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/docs/README.md b/docs/README.md index 7e6805f..0f1ffa1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,8 @@ 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 release deployment | `tencent-release-deployment.md` / `tencent-release-deployment.zh-CN.md` | Tencent main/release deployment, runner, branch, and port mapping. | +| Tencent Docker deployment | `tencent-docker-deployment.md` / `tencent-docker-deployment.zh-CN.md` | Docker/source 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. | | UI validation | `webui-visual-checklist.md` / `webui-visual-checklist.zh-CN.md` | Theme, layout, responsive, screenshot checklist. | @@ -37,6 +38,7 @@ This is the single entry point for MeteorTest documentation. Start here for new - Supabase SQL execution: `supabase-account-data-runbook.md`. - Agent startup, daemon, or troubleshooting: `local-agent-operations.md`. - Public preview deployment: `vercel-public-preview.md`. +- Production release automation: `release-manager.md`. - Public Web plus private Agent validation: `private-agent-preview-loop.md`. - UUID exposure or API DTO work: `internal-id-exposure-hardening.md`. - UI, responsive, or theme checks: `webui-visual-checklist.md`. diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 23db1f8..f926314 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -23,7 +23,8 @@ | 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 公网预览部署和安全检查。 | -| 腾讯云 Release 部署 | `tencent-release-deployment.zh-CN.md` / `tencent-release-deployment.md` | 腾讯云 main/release 部署、runner、分支和端口映射。 | +| 腾讯云 Docker 部署 | `tencent-docker-deployment.zh-CN.md` / `tencent-docker-deployment.md` | Docker + 源码 Artifact 架构、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 规则。 | | UI 验收 | `webui-visual-checklist.zh-CN.md` / `webui-visual-checklist.md` | WebUI 主题、布局、响应式和截图验收清单。 | @@ -37,6 +38,7 @@ - 要执行 Supabase SQL:读 `supabase-account-data-runbook.zh-CN.md`。 - 要启动、常驻或排查 Agent:读 `local-agent-operations.zh-CN.md`。 - 要部署公网预览:读 `vercel-public-preview.zh-CN.md`。 +- 要做生产发布:读 `release-manager.md`。 - 要验证公网 Web + 私有 Agent 闭环:读 `private-agent-preview-loop.zh-CN.md`。 - 要处理 UUID 暴露或 API DTO:读 `internal-id-exposure-hardening.zh-CN.md`。 - 要做 UI / 响应式 / 主题检查:读 `webui-visual-checklist.zh-CN.md`。 diff --git a/docs/release-manager.md b/docs/release-manager.md new file mode 100644 index 0000000..6996f4c --- /dev/null +++ b/docs/release-manager.md @@ -0,0 +1,50 @@ +# Release Manager + +MeteorTest uses `Release Manager` to turn the release flow into one manual GitHub Actions run while keeping the protected branch model. + +## Entry Point + +Open: + +```text +GitHub -> Actions -> Release Manager -> Run workflow +``` + +Inputs: + +- `version`: semantic version without the `v` prefix, for example `0.1.3`. +- `action`: + - `full`: prepare version files, promote `main` to `release`, wait for Tencent deployment, verify URLs, and create the GitHub Release. + - `prepare`: only create and merge the version/release-note PR into `main`. + - `promote`: only create and merge the `main` -> `release` PR, deploy, and create the GitHub Release. + - `verify`: only check the production and preview URLs. + +## Required Secret + +For true one-click releases, configure repository secret `RELEASE_MANAGER_TOKEN` with a fine-grained GitHub token that can read/write contents, issues, pull requests, actions, and releases for this repository. + +The workflow falls back to `GITHUB_TOKEN` if the secret is missing, but GitHub may not trigger follow-up CI checks for branches pushed by `GITHUB_TOKEN`. In that fallback mode, use `prepare` and `promote` as recovery steps instead of expecting a fully unattended release. + +## What Full Release Does + +1. Checks that tag `v` does not already exist. +2. Creates or reuses a release tracking issue. +3. Creates a release preparation branch from `origin/main`. +4. Updates Web package versions and creates `docs/releases/v.md` when needed. +5. Opens a PR into `main`, waits for checks, and merges it. +6. Opens a PR from `main` into `release`, waits for checks, and merges it. +7. Waits for the Tencent release deployment workflow. +8. Creates GitHub Release `v`. +9. Verifies the public URLs. + +The workflow does not push directly to `main` or `release`. + +## Local Recovery + +If a run stops after one phase, continue locally or from Actions: + +```bash +node scripts/release-manager.mjs prepare --version 0.1.3 +node scripts/release-manager.mjs promote --version 0.1.3 +node scripts/release-manager.mjs verify --version 0.1.3 +``` diff --git a/docs/releases/v0.1.3.md b/docs/releases/v0.1.3.md new file mode 100644 index 0000000..2369de6 --- /dev/null +++ b/docs/releases/v0.1.3.md @@ -0,0 +1,29 @@ +# Release Notes + +Release focus: production promotion for MeteorTest 0.1.3. + +## Highlights + +- Promoted validated main branch changes to the production release branch. +- Updated Web package version to `0.1.3`. +- Published GitHub Release tag `v0.1.3`. + +## Deployment + +- Production branch: `release` +- Preview branch: `main` +- Production URL: `https://meteortest.jcmeteor.com/` +- Preview URL: `https://mt-pre.jcmeteor.com/` + +## Versioning + +- Web version: `0.1.3` +- Release tag: `v0.1.3` + +## Validation + +```bash +cd apps/web +npm run lint +npm run build +``` diff --git a/docs/tencent-docker-deployment.md b/docs/tencent-docker-deployment.md new file mode 100644 index 0000000..bd4893d --- /dev/null +++ b/docs/tencent-docker-deployment.md @@ -0,0 +1,110 @@ +# Tencent Docker Deployment + +This runbook defines the target Docker deployment for the MeteorTest Web Console on the existing Tencent server. The private Python Local Agent, hosted Supabase project, and host Nginx remain outside the Web container. + +> Status: target design. The active Tencent deployment still uses PM2 until the migration acceptance checklist is complete. + +## Environment mapping + +| Branch | Environment | Domain | Host port | Compose project | Runtime env | +| --- | --- | --- | ---: | --- | --- | +| `main` | Preview | `mt-pre.jcmeteor.com` / `mt-pre-cn.jcmeteor.com` | `3201` | `meteortest-preview` | `/etc/meteortest/meteortest-web.env` | +| `release` | Production | `meteortest.jcmeteor.com` / `mt-cn.jcmeteor.com` | `3200` | `meteortest-production` | `/etc/meteortest/meteortest-web.env` | + +Nginx MUST continue binding public ports 80/443. Containers MUST publish only to `127.0.0.1`. + +## Target delivery flow + +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 validates the Docker build on pull requests and uploads compact source as a GitHub Actions artifact after merge. +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 builds from the artifact with the server-side Docker layer cache 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. + +The server MUST NOT copy source, install npm dependencies, or build Next.js after migration. + +## Image contract + +- 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. +- The runtime process MUST run as a non-root user. +- Secrets MUST NOT be copied or passed as Docker build arguments. +- `.dockerignore` MUST exclude `.git`, `.env*`, local build output, logs, reports, and private Agent configuration. + +## Web and Local Agent boundary + +Docker migration covers only `apps/web`. The private Python Local Agent MUST remain independently operated and MUST NOT be embedded in the public Web image. + +- Public preview keeps `METEORTEST_AGENT_DISABLED=1` and `METEORTEST_PUBLIC_PREVIEW=1`. +- `/api/agent/status` and the Executors UI MUST NOT start a machine-local Agent from the container. +- Agent credentials, repository paths, and `agent/config.yaml` MUST NOT enter the Web image. +- A future Agent containerization requires a separate security and execution-isolation design. + +## Configuration and secrets + +Real Web credentials remain in `/etc/meteortest/meteortest-web.env`. Compose injects the file at container startup. The self-hosted runner downloads the validated source artifact, builds with the server-side Docker layer cache, and deploys it. It does not pull the Git repository and requires no container-registry password. Supabase service-role, AI provider, Agent, and project execution secrets MUST NOT enter the source artifact or image. + +## Compose requirements + +Preview and production MUST use separate Compose project names, containers, and networks. Each service MUST define: + +- `restart: unless-stopped`; +- a Web health check; +- JSON log rotation (`max-size: 10m`, `max-file: 3`); +- a memory limit appropriate for the 3.6 GiB host; +- an immutable image SHA; +- `127.0.0.1:3201` or `127.0.0.1:3200` host binding. + +Deployment metadata MAY live under `/srv/containers/meteortest/{preview,production}`. Application secrets MUST stay under `/etc/meteortest`. + +## First migration from PM2 + +Migrate one environment at a time, preview before production. + +1. Record the current Git commit, PM2 process, Nginx configuration, and public health result. +2. Build and push the candidate image without changing the active runtime. +3. Start a shadow container on an unused localhost port and verify primary pages, authentication, API routes, and public-preview Agent-disabled behavior. +4. Switch only the relevant Nginx upstream to the shadow container and run public checks. +5. Stop only `meteortest-web` or `meteortest-release`. +6. Start the final Compose project on the existing `3201` or `3200` port and return Nginx to that port. +7. Observe logs and health before migrating the next environment. + +Do not run `pm2 kill`. Keep PM2 definitions and source directories until both environments pass the observation window. + +## Routine deployment + +1. resolve the immutable image SHA; +2. record the currently running SHA; +3. download the source artifact and build the image with Docker layer cache; +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. + +## Rollback + +For a normal rollback, deploy the previous image SHA and repeat health checks. During the first migration, stop the failed Compose project, restore Nginx if required, restart only the matching PM2 process, and verify the original port and domain. + +## Acceptance checklist + +- CI builds the same commit that is tagged and deployed. +- 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. +- PM2 rollback works before definitions are removed. +- Preview and production domains return HTTP 200. +- Authentication, project/task/report APIs, AI surfaces, and public-preview safety checks pass. +- The private Local Agent remains independently operable. +- Docker logs rotate and old images have a retention policy. + +## Related documentation + +- `docs/release-manager.md`: release automation. +- `docs/private-agent-preview-loop.md`: public Web and private Agent validation. +- `docs/local-agent-operations.md`: Local Agent operations. +- `docs/vercel-public-preview.md`: hosted public preview boundary. diff --git a/docs/tencent-docker-deployment.zh-CN.md b/docs/tencent-docker-deployment.zh-CN.md new file mode 100644 index 0000000..3e80fc5 --- /dev/null +++ b/docs/tencent-docker-deployment.zh-CN.md @@ -0,0 +1,110 @@ +# 腾讯云 Docker 部署 + +本文定义 MeteorTest Web Console 在现有腾讯云服务器上的目标 Docker 部署方式。私有 Python Local Agent、托管 Supabase 和宿主机 Nginx 不进入 Web 容器。 + +> 状态:目标设计。完成本文迁移验收清单之前,腾讯云当前仍使用 PM2 部署。 + +## 环境映射 + +| 分支 | 环境 | 域名 | 宿主机端口 | Compose 项目 | 运行时环境文件 | +| --- | --- | --- | ---: | --- | --- | +| `main` | 预览 | `mt-pre.jcmeteor.com` / `mt-pre-cn.jcmeteor.com` | `3201` | `meteortest-preview` | `/etc/meteortest/meteortest-web.env` | +| `release` | 生产 | `meteortest.jcmeteor.com` / `mt-cn.jcmeteor.com` | `3200` | `meteortest-production` | `/etc/meteortest/meteortest-web.env` | + +Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0.1`。 + +## 目标交付流程 + +1. GitHub 托管 Runner 检出目标提交。 +2. CI 在 `apps/web` 安装依赖并执行 lint 和生产构建;仓库级验证继续单独覆盖 Python Agent。 +3. CI 在 PR 中验证 Docker 构建;合并后将精简源码作为 GitHub Actions Artifact 上传。 +4. 镜像使用不可变 commit SHA 标签;分支和版本标签 MAY 作为别名,但部署 MUST 最终解析到 SHA 标签。 +5. MeteorTest 专属腾讯 Runner 下载 Artifact、将不可变镜像加载到 Docker,并只更新对应 Compose 项目。 +6. Runner 等待容器健康并验证公网域名。 +7. 健康检查失败时 MUST 恢复上一镜像 SHA。 + +迁移完成后,服务器 MUST 不再复制源码、安装 npm 依赖或构建 Next.js。 + +## 镜像契约 + +- 镜像标签:`meteortest-web:`。 +- 构建上下文:默认使用 `apps/web`;如果实施验证发现仓库根依赖,再调整到根目录。 +- Next.js MUST 使用 `output: 'standalone'`。 +- 运行阶段 MUST 只包含 standalone server、静态资源和必要 public 文件。 +- 运行进程 MUST 使用非 root 用户。 +- 密钥 MUST NOT 复制进镜像或通过 Docker build argument 传入。 +- `.dockerignore` MUST 排除 `.git`、`.env*`、本地构建输出、日志、报告和私有 Agent 配置。 + +## Web 与 Local Agent 边界 + +本次 Docker 迁移只覆盖 `apps/web`。私有 Python Local Agent MUST 继续独立运行,MUST NOT 嵌入公网 Web 镜像。 + +- 公共预览继续设置 `METEORTEST_AGENT_DISABLED=1` 和 `METEORTEST_PUBLIC_PREVIEW=1`。 +- `/api/agent/status` 和 Executors UI MUST NOT 从容器启动服务器本地 Agent。 +- Agent 凭据、仓库路径和 `agent/config.yaml` MUST NOT 进入 Web 镜像。 +- 如果未来容器化 Agent,必须另行设计执行隔离和安全边界。 + +## 配置与密钥 + +真实 Web 凭据继续保存在 `/etc/meteortest/meteortest-web.env`,由 Compose 在容器启动时注入。自托管 Runner 下载经验证的源码 Artifact,利用服务器 Docker 层缓存构建并部署,不拉取 Git 仓库,也不需要镜像仓库密码。Supabase service-role、AI provider、Agent 和项目执行密钥 MUST NOT 进入源码 Artifact 或镜像。 + +## Compose 要求 + +预览和生产 MUST 使用不同的 Compose 项目名、容器和网络。每个服务 MUST 定义: + +- `restart: unless-stopped`; +- Web 健康检查; +- JSON 日志轮转(`max-size: 10m`、`max-file: 3`); +- 适配当前 3.6 GiB 服务器的内存限制; +- 不可变镜像 SHA; +- `127.0.0.1:3201` 或 `127.0.0.1:3200` 绑定。 + +部署元数据 MAY 放在 `/srv/containers/meteortest/{preview,production}`;应用密钥 MUST 保留在 `/etc/meteortest`。 + +## 首次从 PM2 迁移 + +每次只迁移一个环境,必须先预览后生产。 + +1. 记录当前 Git commit、PM2 进程、Nginx 配置和公网健康结果。 +2. 构建并推送候选镜像,不改变当前运行状态。 +3. 在未使用的 localhost 端口启动影子容器,验证主要页面、认证、API 路由和公共预览 Agent 禁用行为。 +4. 只将当前环境的 Nginx upstream 切到影子容器并执行公网检查。 +5. 只停止 `meteortest-web` 或 `meteortest-release`。 +6. 在原 `3201` 或 `3200` 端口启动最终 Compose 项目,并将 Nginx 恢复指向该端口。 +7. 观察日志和健康状态后,再迁移下一个环境。 + +禁止执行 `pm2 kill`。两个环境完成观察期之前,保留 PM2 定义和源码目录。 + +## 日常部署 + +1. 解析新的不可变镜像 SHA; +2. 记录当前运行 SHA; +3. 下载源码 Artifact,并利用 Docker 层缓存构建镜像; +4. 使用本地不可变镜像更新对应 Compose 项目; +5. 等待容器健康; +6. 验证 localhost 端口和公网域名; +7. 保留上一 SHA 用于回滚。 + +## 回滚 + +普通回滚应部署上一镜像 SHA 并重复健康检查。首次迁移期间,应停止失败的 Compose 项目,必要时恢复 Nginx,只重启对应 PM2 进程,并验证原端口和域名。 + +## 验收清单 + +- CI 构建、标记和部署的是同一个 commit。 +- 镜像历史、构建日志和 Artifact 元数据中不存在应用或 Agent 密钥。 +- 预览与生产可独立部署、独立回滚。 +- 容器只绑定 localhost。 +- reload 前 Nginx 配置通过 `nginx -t`。 +- 删除 PM2 定义前完成 PM2 回滚验证。 +- 预览和生产域名均返回 HTTP 200。 +- 完成认证、项目/任务/报告 API、AI 功能和公共预览安全检查。 +- 私有 Local Agent 继续独立可用。 +- Docker 日志已轮转,旧镜像有明确保留策略。 + +## 相关文档 + +- `docs/release-manager.md`:发布自动化。 +- `docs/private-agent-preview-loop.zh-CN.md`:公网 Web 与私有 Agent 验证。 +- `docs/local-agent-operations.zh-CN.md`:Local Agent 运维。 +- `docs/vercel-public-preview.zh-CN.md`:托管公网预览边界。 diff --git a/docs/tencent-release-deployment.md b/docs/tencent-release-deployment.md deleted file mode 100644 index da724ea..0000000 --- a/docs/tencent-release-deployment.md +++ /dev/null @@ -1,72 +0,0 @@ -# Tencent Release Deployment - -MeteorTest uses two server-side Tencent deployments: - -```text -release branch -> /srv/meteortest-release -> 127.0.0.1:3200 -> meteortest.jcmeteor.com / mt-cn.jcmeteor.com -main branch -> /srv/meteortest -> 127.0.0.1:3201 -> mt-pre.jcmeteor.com / mt-pre-cn.jcmeteor.com -``` - -The public Nginx entry point is port `80`. App ports are bound to `127.0.0.1` only and must not be opened in the Tencent security group. - -## GitHub Setup - -- Branches: - - `main`: integration and preview deployment branch. - - `release`: production release branch. -- Rulesets: - - `Protect main`: existing main protection. - - `Protect release`: requires the `CI` status check and prevents deletion/non-fast-forward updates. -- Runner: - - Repository runner: `tencent-meteortest` - - Labels: `self-hosted`, `linux`, `x64`, `tencent`, `meteortest` -- Workflow: - - `.github/workflows/ci.yml`: validates `main`, `release`, and `dev/v-peq/**`. - - `.github/workflows/deploy-tencent.yml`: deploys `main` and `release`. - -## Server Environment - -Runtime environment variables live on the Tencent server: - -```text -/etc/meteortest/meteortest-web.env -``` - -Do not commit real values. The deploy workflow sources this file before building and starting the Next.js app. - -## Release Flow - -1. Merge feature work into `main`. -2. Let `main` deploy to the preview endpoint: - ```text - mt-pre.jcmeteor.com - mt-pre-cn.jcmeteor.com - ``` -3. Open a PR from `main` into `release`. -4. Wait for `CI`. -5. Merge into `release`. -6. The Tencent deploy workflow updates: - ```text - /srv/meteortest-release - meteortest-release - 127.0.0.1:3200 - ``` -7. Create a GitHub Release tag from `release`. - -## Release Baselines - -The first branch baseline is: - -```text -v0.1.0 -``` - -It marks the initial release branch creation point. - -The current Tencent deployment baseline is: - -```text -v0.1.2 -``` - -It includes the protected release branch, self-hosted Tencent runner deployment, production/preview split, and the `3200/3201` MeteorTest port alignment. diff --git a/docs/tencent-release-deployment.zh-CN.md b/docs/tencent-release-deployment.zh-CN.md deleted file mode 100644 index a34f7d1..0000000 --- a/docs/tencent-release-deployment.zh-CN.md +++ /dev/null @@ -1,72 +0,0 @@ -# 腾讯云 Release 部署 - -MeteorTest 在腾讯云上使用两套服务端部署: - -```text -release 分支 -> /srv/meteortest-release -> 127.0.0.1:3200 -> meteortest.jcmeteor.com / mt-cn.jcmeteor.com -main 分支 -> /srv/meteortest -> 127.0.0.1:3201 -> mt-pre.jcmeteor.com / mt-pre-cn.jcmeteor.com -``` - -公网 Nginx 入口只开放 `80`。应用端口只绑定 `127.0.0.1`,不要在腾讯云安全组中开放。 - -## GitHub 配置 - -- 分支: - - `main`:集成和预发部署分支。 - - `release`:生产发布分支。 -- Rulesets: - - `Protect main`:已有 main 保护。 - - `Protect release`:要求 `CI` 状态检查,禁止删除和非快进更新。 -- Runner: - - 仓库 runner:`tencent-meteortest` - - 标签:`self-hosted`、`linux`、`x64`、`tencent`、`meteortest` -- Workflow: - - `.github/workflows/ci.yml`:验证 `main`、`release` 和 `dev/v-peq/**`。 - - `.github/workflows/deploy-tencent.yml`:部署 `main` 和 `release`。 - -## 服务器环境变量 - -运行时环境变量放在腾讯云服务器: - -```text -/etc/meteortest/meteortest-web.env -``` - -不要提交真实值。部署 workflow 会在构建和启动 Next.js 前读取这个文件。 - -## 发布流程 - -1. 功能变更先合入 `main`。 -2. `main` 自动部署到预发入口: - ```text - mt-pre.jcmeteor.com - mt-pre-cn.jcmeteor.com - ``` -3. 从 `main` 向 `release` 开 PR。 -4. 等待 `CI` 通过。 -5. 合入 `release`。 -6. 腾讯云部署 workflow 更新: - ```text - /srv/meteortest-release - meteortest-release - 127.0.0.1:3200 - ``` -7. 从 `release` 创建 GitHub Release tag。 - -## Release 基线 - -第一个分支基线是: - -```text -v0.1.0 -``` - -它标记 release 分支建立时的初始位置。 - -当前腾讯云部署基线是: - -```text -v0.1.2 -``` - -它包含受保护的 release 分支、腾讯云 self-hosted runner 部署、生产/预发拆分,以及 MeteorTest `3200/3201` 端口对齐。 diff --git a/scripts/release-manager.mjs b/scripts/release-manager.mjs new file mode 100644 index 0000000..8e2adc2 --- /dev/null +++ b/scripts/release-manager.mjs @@ -0,0 +1,380 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) + +const config = { + repo: 'JunchenMeteor/MeteorTest', + projectName: 'MeteorTest', + branchPrefix: 'dev/v-peq/release', + issuePrefix: '[Feature]', + issueLabel: 'enhancement', + versionFiles: ['apps/web/package.json', 'apps/web/package-lock.json'], + releaseDoc: (version) => `docs/releases/v${version}.md`, + releaseUrls: ['https://meteortest.jcmeteor.com/', 'https://mt-pre.jcmeteor.com/'], + deployWorkflow: 'Deploy Tencent', +} + +const args = process.argv.slice(2) +const command = args[0] ?? 'help' +const options = parseOptions(args.slice(1)) + +if (command === 'help' || options.help) { + printHelp() + process.exit(command === 'help' ? 0 : 1) +} + +const version = normalizeVersion(options.version) +const tag = `v${version}` +const prepareTitle = `${config.issuePrefix} Prepare ${tag} release` +const releaseTitle = `${config.issuePrefix} Release ${tag}` +const dryRun = options.dryRun === true + +switch (command) { + case 'full': + await fullRelease() + break + case 'prepare': + await prepareRelease() + break + case 'promote': + await promoteRelease() + break + case 'verify': + await verifyRelease() + break + default: + fail(`Unknown command: ${command}`) +} + +async function fullRelease() { + await prepareRelease() + await promoteRelease() + await verifyRelease() +} + +async function prepareRelease() { + ensureTooling() + ensureCleanWorktree() + fetchBase() + ensureTagDoesNotExist(tag) + + if (mainAlreadyPrepared(version)) { + log(`main already contains ${tag}; skipping preparation PR`) + return + } + + const issue = createOrFindIssue(prepareTitle, issueBody('Prepare release version files and release notes.')) + const branch = `${config.branchPrefix}${tag.replaceAll('.', '')}` + + run('git', ['checkout', '-B', branch, 'origin/main']) + updateVersionFiles(version) + writeReleaseDoc(version) + + run('git', ['add', ...config.versionFiles.filter(exists), config.releaseDoc(version)]) + run('git', ['commit', '-m', `Prepare ${tag} release`]) + run('git', ['push', '-u', 'origin', branch]) + + const pr = createOrFindPr(prepareTitle, branch, 'main', prBody(issue.number, 'Prepare the release version files and release notes.')) + waitForPrChecks(pr.number) + mergePr(pr.number, true) + log(`Prepared ${tag} on main through PR #${pr.number}`) +} + +async function promoteRelease() { + ensureTooling() + fetchBase() + ensureTagDoesNotExist(tag) + + const issue = createOrFindIssue(releaseTitle, issueBody('Promote main to release and publish the GitHub Release.')) + const pr = createOrFindPr(releaseTitle, 'main', 'release', prBody(issue.number, 'Promote main to the protected production release branch.')) + + waitForPrChecks(pr.number) + const mergeCommit = mergePr(pr.number, false) + waitForDeploy('release', mergeCommit) + createGithubRelease(tag, mergeCommit) + closeIssue(issue.number) + log(`Released ${tag}: https://github.com/${config.repo}/releases/tag/${tag}`) +} + +async function verifyRelease() { + for (const url of config.releaseUrls) { + run('curl', ['-I', '--max-time', '15', url]) + } +} + +function parseOptions(rawArgs) { + const parsed = {} + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index] + if (!arg.startsWith('--')) continue + const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()) + const next = rawArgs[index + 1] + if (!next || next.startsWith('--')) { + parsed[key] = true + } else { + parsed[key] = next + index += 1 + } + } + return parsed +} + +function normalizeVersion(value) { + if (!value || typeof value !== 'string') { + fail('Missing --version, for example: --version 0.1.3') + } + const normalized = value.replace(/^v/, '') + if (!/^\d+\.\d+\.\d+$/.test(normalized)) { + fail(`Invalid version "${value}". Use semver like 0.1.3.`) + } + return normalized +} + +function ensureTooling() { + run('git', ['--version']) + run('gh', ['--version']) + run('curl', ['--version']) + run('git', ['config', 'user.name', 'github-actions[bot]'], { allowFail: true }) + run('git', ['config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com'], { allowFail: true }) +} + +function ensureCleanWorktree() { + const status = capture('git', ['status', '--porcelain']) + if (status.trim()) fail(`Worktree is not clean:\n${status}`) +} + +function fetchBase() { + run('git', ['fetch', 'origin', 'main', 'release', '--tags']) +} + +function ensureTagDoesNotExist(tagName) { + const existing = capture('git', ['tag', '--list', tagName]).trim() + if (existing) fail(`Tag ${tagName} already exists.`) +} + +function mainAlreadyPrepared(targetVersion) { + run('git', ['checkout', 'origin/main']) + return readJson('apps/web/package.json').version === targetVersion && existsSync(config.releaseDoc(targetVersion)) +} + +function updateVersionFiles(targetVersion) { + for (const file of config.versionFiles) { + if (!existsSync(file)) continue + const json = readJson(file) + if (file.endsWith('package-lock.json')) { + json.version = targetVersion + if (json.packages?.['']) json.packages[''].version = targetVersion + } else { + json.version = targetVersion + } + writeJson(file, json) + } +} + +function writeReleaseDoc(targetVersion) { + const file = config.releaseDoc(targetVersion) + if (existsSync(file)) return + writeFileSync( + file, + `# Release Notes + +Release focus: production promotion for ${config.projectName} ${targetVersion}. + +## Highlights + +- Promoted validated main branch changes to the production release branch. +- Updated Web package version to \`${targetVersion}\`. +- Published GitHub Release tag \`v${targetVersion}\`. + +## Deployment + +- Production branch: \`release\` +- Preview branch: \`main\` +- Production URL: \`https://meteortest.jcmeteor.com/\` +- Preview URL: \`https://mt-pre.jcmeteor.com/\` + +## Versioning + +- Web version: \`${targetVersion}\` +- Release tag: \`v${targetVersion}\` + +## Validation + +\`\`\`bash +cd apps/web +npm run lint +npm run build +\`\`\` +`, + ) +} + +function createOrFindIssue(title, body) { + const existing = JSON.parse(capture('gh', ['issue', 'list', '--repo', config.repo, '--state', 'all', '--search', `${JSON.stringify(title)} in:title`, '--json', 'number,title,state,url', '--limit', '20'])) + .find((issue) => issue.title === title) + if (existing) return existing + + if (dryRun) return { number: 0, title, url: 'dry-run' } + const output = capture('gh', ['issue', 'create', '--repo', config.repo, '--title', title, '--body', body, '--label', config.issueLabel]) + return parseIssueUrl(output.trim()) +} + +function createOrFindPr(title, head, base, body) { + const existing = JSON.parse(capture('gh', ['pr', 'list', '--repo', config.repo, '--state', 'open', '--head', head, '--base', base, '--json', 'number,title,url', '--limit', '20'])) + .find((pr) => pr.title === title) + if (existing) return existing + + if (dryRun) return { number: 0, title, url: 'dry-run' } + const output = capture('gh', ['pr', 'create', '--repo', config.repo, '--title', title, '--body', body, '--head', head, '--base', base]) + return parsePrUrl(output.trim()) +} + +function waitForPrChecks(number) { + if (dryRun) return + run('gh', ['pr', 'checks', String(number), '--repo', config.repo, '--watch']) +} + +function mergePr(number, deleteBranch) { + if (dryRun) return capture('git', ['rev-parse', 'origin/main']).trim() + const args = ['pr', 'merge', String(number), '--repo', config.repo, '--merge'] + if (deleteBranch) args.push('--delete-branch') + run('gh', args) + const pr = JSON.parse(capture('gh', ['pr', 'view', String(number), '--repo', config.repo, '--json', 'mergeCommit'])) + return pr.mergeCommit?.oid ?? '' +} + +function waitForDeploy(branch, headSha) { + if (dryRun) return + const startedAt = Date.now() + for (;;) { + const runs = JSON.parse(capture('gh', ['run', 'list', '--repo', config.repo, '--branch', branch, '--workflow', config.deployWorkflow, '--json', 'databaseId,status,conclusion,headSha,createdAt', '--limit', '10'])) + const runInfo = runs.find((item) => item.headSha === headSha) ?? runs[0] + if (runInfo?.status === 'completed') { + if (runInfo.conclusion !== 'success') fail(`${config.deployWorkflow} failed with conclusion: ${runInfo.conclusion}`) + return + } + if (Date.now() - startedAt > 20 * 60 * 1000) fail(`Timed out waiting for ${config.deployWorkflow}`) + log(`Waiting for ${config.deployWorkflow} on ${branch}...`) + sleep(15_000) + } +} + +function createGithubRelease(tagName, target) { + if (dryRun) return + const existing = capture('gh', ['release', 'list', '--repo', config.repo, '--json', 'tagName', '--limit', '100']) + if (JSON.parse(existing).some((release) => release.tagName === tagName)) return + run('gh', ['release', 'create', tagName, '--repo', config.repo, '--target', target, '--title', tagName, '--notes-file', config.releaseDoc(version), '--latest']) +} + +function closeIssue(number) { + if (dryRun || number === 0) return + const issue = JSON.parse(capture('gh', ['issue', 'view', String(number), '--repo', config.repo, '--json', 'state'])) + if (issue.state !== 'CLOSED') { + run('gh', ['issue', 'close', String(number), '--repo', config.repo, '--comment', `Released ${tag}: https://github.com/${config.repo}/releases/tag/${tag}`]) + } +} + +function issueBody(summary) { + return `## Summary + +${summary} + +## Expected Behavior + +The release is promoted from main to release through protected PR checks, deployed to Tencent production, and published as a GitHub Release. + +## Proposed Changes + +- Update release version files and release notes when needed. +- Promote main to release through a pull request. +- Wait for deployment validation before publishing the GitHub Release. + +## Test Plan + +- Automated Release Manager PR checks. +- Tencent release deployment workflow. +- Production URL verification.` +} + +function prBody(issueNumber, summary) { + return `## Summary + +${summary} + +## Test Plan + +- Automated CI, build, CodeQL, and deployment checks. +- Release Manager verification. + +Closes #${issueNumber}` +} + +function readJson(file) { + return JSON.parse(readFileSync(file, 'utf8')) +} + +function writeJson(file, value) { + writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`) +} + +function exists(file) { + return existsSync(file) +} + +function parseIssueUrl(url) { + const match = url.match(/issues\/(\d+)/) + if (!match) fail(`Could not parse issue URL: ${url}`) + return { number: Number(match[1]), url } +} + +function parsePrUrl(url) { + const match = url.match(/pull\/(\d+)/) + if (!match) fail(`Could not parse PR URL: ${url}`) + return { number: Number(match[1]), url } +} + +function capture(commandName, commandArgs) { + return run(commandName, commandArgs, { capture: true }) +} + +function run(commandName, commandArgs, options = {}) { + const printable = `${commandName} ${commandArgs.join(' ')}` + log(printable) + if (dryRun && !options.capture && !['git', 'gh'].includes(commandName)) return '' + try { + return execFileSync(commandName, commandArgs, { + cwd: rootDir, + encoding: 'utf8', + stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }) + } catch (error) { + if (options.allowFail) return '' + const stderr = error.stderr?.toString?.() ?? '' + fail(`${printable} failed${stderr ? `:\n${stderr}` : ''}`) + } +} + +function sleep(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +} + +function log(message) { + console.log(`[release-manager] ${message}`) +} + +function fail(message) { + console.error(`[release-manager] ${message}`) + process.exit(1) +} + +function printHelp() { + console.log(`Usage: + node scripts/release-manager.mjs full --version 0.1.3 + node scripts/release-manager.mjs prepare --version 0.1.3 + node scripts/release-manager.mjs promote --version 0.1.3 + node scripts/release-manager.mjs verify --version 0.1.3`) +}