From 06eb1bb07d319edcce04eec8fda414030a98d94d Mon Sep 17 00:00:00 2001 From: ConnorQi Date: Sat, 6 Jun 2026 16:43:08 +0800 Subject: [PATCH 1/7] Add release manager workflow --- .github/workflows/release-manager.yml | 51 +++ README.md | 8 + README.zh-CN.md | 8 + docs/README.md | 2 + docs/README.zh-CN.md | 2 + docs/release-manager.md | 50 +++ docs/tencent-release-deployment.md | 5 + docs/tencent-release-deployment.zh-CN.md | 5 + scripts/release-manager.mjs | 380 +++++++++++++++++++++++ 9 files changed, 511 insertions(+) create mode 100644 .github/workflows/release-manager.yml create mode 100644 docs/release-manager.md create mode 100644 scripts/release-manager.mjs 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/README.md b/README.md index 6b53bd1..655d4c0 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,14 @@ release -> meteortest.jcmeteor.com The detailed runner, branch, ruleset, environment, and port mapping lives in `docs/tencent-release-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 1. Run Supabase migrations. diff --git a/README.zh-CN.md b/README.zh-CN.md index 594f76e..cd266c3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -253,6 +253,14 @@ release -> meteortest.jcmeteor.com runner、分支、ruleset、环境变量和端口映射详见 `docs/tencent-release-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`。 + ## 推荐验证流程 1. 执行 Supabase 迁移。 diff --git a/docs/README.md b/docs/README.md index 7e6805f..c77b373 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ This is the single entry point for MeteorTest documentation. Start here for new | 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. | +| 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..c1b9b97 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -24,6 +24,7 @@ | 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、分支和端口映射。 | +| 发布自动化 | `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/tencent-release-deployment.md b/docs/tencent-release-deployment.md index da724ea..f2c6060 100644 --- a/docs/tencent-release-deployment.md +++ b/docs/tencent-release-deployment.md @@ -23,6 +23,7 @@ The public Nginx entry point is port `80`. App ports are bound to `127.0.0.1` on - Workflow: - `.github/workflows/ci.yml`: validates `main`, `release`, and `dev/v-peq/**`. - `.github/workflows/deploy-tencent.yml`: deploys `main` and `release`. + - `.github/workflows/release-manager.yml`: orchestrates version preparation, release PRs, Tencent deployment verification, and GitHub Release publishing. ## Server Environment @@ -36,6 +37,10 @@ Do not commit real values. The deploy workflow sources this file before building ## Release Flow +Use `GitHub -> Actions -> Release Manager -> Run workflow` with `action=full` for normal releases. The detailed automation and recovery commands live in `docs/release-manager.md`. + +The underlying release flow is: + 1. Merge feature work into `main`. 2. Let `main` deploy to the preview endpoint: ```text diff --git a/docs/tencent-release-deployment.zh-CN.md b/docs/tencent-release-deployment.zh-CN.md index a34f7d1..7b7ff9a 100644 --- a/docs/tencent-release-deployment.zh-CN.md +++ b/docs/tencent-release-deployment.zh-CN.md @@ -23,6 +23,7 @@ main 分支 -> /srv/meteortest -> 127.0.0.1:3201 -> mt-pre.jcmeteor.c - Workflow: - `.github/workflows/ci.yml`:验证 `main`、`release` 和 `dev/v-peq/**`。 - `.github/workflows/deploy-tencent.yml`:部署 `main` 和 `release`。 + - `.github/workflows/release-manager.yml`:编排版本准备、release PR、腾讯云部署验证和 GitHub Release 发布。 ## 服务器环境变量 @@ -36,6 +37,10 @@ main 分支 -> /srv/meteortest -> 127.0.0.1:3201 -> mt-pre.jcmeteor.c ## 发布流程 +正常发布使用 `GitHub -> Actions -> Release Manager -> Run workflow`,选择 `action=full`。自动化细节和中断恢复命令见 `docs/release-manager.md`。 + +底层发布流程是: + 1. 功能变更先合入 `main`。 2. `main` 自动部署到预发入口: ```text 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`) +} From 66a6345d7e8eb8df23e5595dd8fe9225f5197cd9 Mon Sep 17 00:00:00 2001 From: ConnorQi Date: Sun, 12 Jul 2026 00:17:30 +0800 Subject: [PATCH 2/7] Document Tencent Docker deployment --- AGENTS.md | 2 +- PROGRESS.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- docs/README.md | 2 +- docs/README.zh-CN.md | 2 +- docs/tencent-docker-deployment.md | 114 +++++++++++++++++++++++ docs/tencent-docker-deployment.zh-CN.md | 114 +++++++++++++++++++++++ docs/tencent-release-deployment.md | 77 --------------- docs/tencent-release-deployment.zh-CN.md | 77 --------------- 10 files changed, 234 insertions(+), 160 deletions(-) create mode 100644 docs/tencent-docker-deployment.md create mode 100644 docs/tencent-docker-deployment.zh-CN.md delete mode 100644 docs/tencent-release-deployment.md delete mode 100644 docs/tencent-release-deployment.zh-CN.md 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 655d4c0..82bbb01 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ 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/TCR target 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 cd266c3..81a1918 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -251,7 +251,7 @@ main -> mt-pre.jcmeteor.com release -> meteortest.jcmeteor.com ``` -runner、分支、ruleset、环境变量和端口映射详见 `docs/tencent-release-deployment.zh-CN.md`。 +Docker/TCR 目标架构、runner、分支、环境变量、端口映射、PM2 迁移和回滚流程详见 `docs/tencent-docker-deployment.zh-CN.md`。 生产发布通过 GitHub Actions 的 `Release Manager` workflow 自动化: diff --git a/docs/README.md b/docs/README.md index c77b373..a86f9fc 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 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/TCR target 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 c1b9b97..7d54f5d 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 公网预览部署和安全检查。 | -| 腾讯云 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/TCR 目标架构、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 new file mode 100644 index 0000000..0c6ccf7 --- /dev/null +++ b/docs/tencent-docker-deployment.md @@ -0,0 +1,114 @@ +# 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 builds a multi-stage Next.js standalone image and pushes it to Tencent Container Registry (TCR). +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. +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 + +- Proposed image: `//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. 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. + +## 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. authenticate to TCR and pull the image; +4. update the matching Compose project; +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 TCR 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..e59ed73 --- /dev/null +++ b/docs/tencent-docker-deployment.zh-CN.md @@ -0,0 +1,114 @@ +# 腾讯云 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 构建多阶段 Next.js standalone 镜像并推送到腾讯云容器镜像服务(TCR)。 +4. 镜像使用不可变 commit SHA 标签;分支和版本标签 MAY 作为别名,但部署 MUST 最终解析到 SHA 标签。 +5. MeteorTest 专属腾讯 Runner 拉取镜像,只更新对应 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 在容器启动时注入。GitHub 只保存 TCR 访问凭据和非敏感部署元数据。 + +- Variables:TCR registry、namespace、image repository; +- Secrets:TCR 用户名/Token 或等价短期凭据; +- Supabase service-role、AI provider、Agent 和项目执行密钥 MUST NOT 进入镜像。 + +## 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. 登录 TCR 并拉取镜像; +4. 更新对应 Compose 项目; +5. 等待容器健康; +6. 验证 localhost 端口和公网域名; +7. 保留上一 SHA 用于回滚。 + +## 回滚 + +普通回滚应部署上一镜像 SHA 并重复健康检查。首次迁移期间,应停止失败的 Compose 项目,必要时恢复 Nginx,只重启对应 PM2 进程,并验证原端口和域名。 + +## 验收清单 + +- CI 构建、标记和部署的是同一个 commit。 +- 镜像历史、构建日志和 TCR 元数据中不存在应用或 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 f2c6060..0000000 --- a/docs/tencent-release-deployment.md +++ /dev/null @@ -1,77 +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`. - - `.github/workflows/release-manager.yml`: orchestrates version preparation, release PRs, Tencent deployment verification, and GitHub Release publishing. - -## 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 - -Use `GitHub -> Actions -> Release Manager -> Run workflow` with `action=full` for normal releases. The detailed automation and recovery commands live in `docs/release-manager.md`. - -The underlying release flow is: - -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 7b7ff9a..0000000 --- a/docs/tencent-release-deployment.zh-CN.md +++ /dev/null @@ -1,77 +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`。 - - `.github/workflows/release-manager.yml`:编排版本准备、release PR、腾讯云部署验证和 GitHub Release 发布。 - -## 服务器环境变量 - -运行时环境变量放在腾讯云服务器: - -```text -/etc/meteortest/meteortest-web.env -``` - -不要提交真实值。部署 workflow 会在构建和启动 Next.js 前读取这个文件。 - -## 发布流程 - -正常发布使用 `GitHub -> Actions -> Release Manager -> Run workflow`,选择 `action=full`。自动化细节和中断恢复命令见 `docs/release-manager.md`。 - -底层发布流程是: - -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` 端口对齐。 From 5809d2e85339ce7f62b141050d9eff15128a7231 Mon Sep 17 00:00:00 2001 From: Connor Date: Sun, 12 Jul 2026 01:13:44 +0800 Subject: [PATCH 3/7] [Feature] Containerize Tencent Web deployment (#130) * Containerize Tencent Web deployment * Deliver Docker image as workflow artifact * fix(deploy): upload image artifacts directly --- .github/workflows/deploy-tencent.yml | 148 +++++++++++++++++------- README.md | 2 +- README.zh-CN.md | 2 +- apps/web/.dockerignore | 10 ++ apps/web/Dockerfile | 32 +++++ apps/web/app/api/health/route.ts | 8 ++ apps/web/next.config.ts | 1 + apps/web/proxy.ts | 2 +- deploy/deploy-container.sh | 104 +++++++++++++++++ deploy/docker-compose.yml | 29 +++++ deploy/receive-image-upload.sh | 27 +++++ docs/README.md | 2 +- docs/README.zh-CN.md | 2 +- docs/tencent-docker-deployment.md | 18 ++- docs/tencent-docker-deployment.zh-CN.md | 18 ++- 15 files changed, 333 insertions(+), 72 deletions(-) create mode 100644 apps/web/.dockerignore create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/app/api/health/route.ts create mode 100644 deploy/deploy-container.sh create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/receive-image-upload.sh 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`。 From 00fb4c3d7a4880c3d398d1d09a7f6d6d8b6201cf Mon Sep 17 00:00:00 2001 From: Connor Date: Sun, 12 Jul 2026 01:21:57 +0800 Subject: [PATCH 4/7] fix(deploy): build from source artifacts (#132) --- .github/workflows/deploy-tencent.yml | 53 +++++++------------------ README.md | 2 +- deploy/receive-image-upload.sh | 27 ------------- docs/README.md | 2 +- docs/README.zh-CN.md | 2 +- docs/tencent-docker-deployment.md | 8 ++-- docs/tencent-docker-deployment.zh-CN.md | 6 +-- 7 files changed, 24 insertions(+), 76 deletions(-) delete mode 100644 deploy/receive-image-upload.sh diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml index c6c905a..cc74175 100644 --- a/.github/workflows/deploy-tencent.yml +++ b/.github/workflows/deploy-tencent.yml @@ -29,9 +29,8 @@ env: jobs: build: - name: Build Web image + name: Validate deployment image runs-on: ubuntu-latest - environment: tencent steps: - name: Checkout uses: actions/checkout@v4 @@ -40,47 +39,27 @@ jobs: 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 - 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 + - name: Upload validated source artifact if: github.event_name != 'pull_request' uses: actions/upload-artifact@v4 with: - name: meteortest-deployment-${{ github.sha }} + 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: @@ -90,11 +69,11 @@ jobs: runs-on: [self-hosted, linux, x64, tencent, meteortest] environment: tencent steps: - - name: Download deployment manifest + - name: Download validated source artifact uses: actions/download-artifact@v4 with: - name: meteortest-deployment-${{ github.sha }} - path: deployment + name: meteortest-source-${{ github.sha }} + path: source - name: Resolve target id: target @@ -114,8 +93,8 @@ jobs: 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: Build image from artifact + run: docker build --tag "meteortest-web:${GITHUB_SHA}" --file source/apps/web/Dockerfile source/apps/web - name: Deploy env: @@ -128,9 +107,5 @@ jobs: RUNTIME_ENV_FILE: /etc/meteortest/meteortest-web.env SHADOW_PORT: ${{ steps.target.outputs.shadow_port }} run: | - chmod +x deployment/deploy-container.sh - deployment/deploy-container.sh - - - name: Remove uploaded image artifact - if: success() - run: rm -f "/srv/deploy-inbox/meteortest/${GITHUB_SHA}.tar.gz" + chmod +x source/deploy/deploy-container.sh + source/deploy/deploy-container.sh diff --git a/README.md b/README.md index fe084ca..5e77394 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ main -> mt-pre.jcmeteor.com release -> meteortest.jcmeteor.com ``` -The detailed Docker image artifact architecture, runner, branch, environment, port mapping, PM2 migration, and rollback flow lives in `docs/tencent-docker-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: diff --git a/deploy/receive-image-upload.sh b/deploy/receive-image-upload.sh deleted file mode 100644 index c6f03c6..0000000 --- a/deploy/receive-image-upload.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/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 cf2cfd5..0f1ffa1 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 image artifact architecture, main/release mapping, PM2 migration, and rollback. | +| 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. | diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index f11ba54..f926314 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 镜像制品架构、main/release 映射、PM2 迁移和回滚。 | +| 腾讯云 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 规则。 | diff --git a/docs/tencent-docker-deployment.md b/docs/tencent-docker-deployment.md index 9fc44c3..bd4893d 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 exports the multi-stage Next.js standalone image as a compressed Docker image artifact. +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 loads the uploaded artifact into Docker and updates only the matching Compose project. +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. @@ -46,7 +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. 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. +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 @@ -79,7 +79,7 @@ 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. download and load the image artifact; +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; diff --git a/docs/tencent-docker-deployment.zh-CN.md b/docs/tencent-docker-deployment.zh-CN.md index 07c744b..3e80fc5 100644 --- a/docs/tencent-docker-deployment.zh-CN.md +++ b/docs/tencent-docker-deployment.zh-CN.md @@ -17,7 +17,7 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. 1. GitHub 托管 Runner 检出目标提交。 2. CI 在 `apps/web` 安装依赖并执行 lint 和生产构建;仓库级验证继续单独覆盖 Python Agent。 -3. CI 将多阶段 Next.js standalone 镜像导出为压缩的 Docker 镜像制品。 +3. CI 在 PR 中验证 Docker 构建;合并后将精简源码作为 GitHub Actions Artifact 上传。 4. 镜像使用不可变 commit SHA 标签;分支和版本标签 MAY 作为别名,但部署 MUST 最终解析到 SHA 标签。 5. MeteorTest 专属腾讯 Runner 下载 Artifact、将不可变镜像加载到 Docker,并只更新对应 Compose 项目。 6. Runner 等待容器健康并验证公网域名。 @@ -46,7 +46,7 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. ## 配置与密钥 -真实 Web 凭据继续保存在 `/etc/meteortest/meteortest-web.env`,由 Compose 在容器启动时注入。GitHub 托管 Runner 使用只允许写入指定目录的 SSH 密钥,将压缩镜像直传到腾讯云制品收件箱;自托管 Runner 只负责加载和部署。该通道不需要镜像仓库密码,上传账号也不能获得交互式 Shell。Supabase service-role、AI provider、Agent 和项目执行密钥 MUST NOT 进入镜像制品。 +真实 Web 凭据继续保存在 `/etc/meteortest/meteortest-web.env`,由 Compose 在容器启动时注入。自托管 Runner 下载经验证的源码 Artifact,利用服务器 Docker 层缓存构建并部署,不拉取 Git 仓库,也不需要镜像仓库密码。Supabase service-role、AI provider、Agent 和项目执行密钥 MUST NOT 进入源码 Artifact 或镜像。 ## Compose 要求 @@ -79,7 +79,7 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. 1. 解析新的不可变镜像 SHA; 2. 记录当前运行 SHA; -3. 下载并加载镜像 Artifact; +3. 下载源码 Artifact,并利用 Docker 层缓存构建镜像; 4. 使用本地不可变镜像更新对应 Compose 项目; 5. 等待容器健康; 6. 验证 localhost 端口和公网域名; From d3e6f60fdcfd4426200dd9ecd9b650bc1990b5b9 Mon Sep 17 00:00:00 2001 From: Connor Date: Sun, 12 Jul 2026 01:34:12 +0800 Subject: [PATCH 5/7] fix(deploy): use regional npm registry (#134) --- .github/workflows/deploy-tencent.yml | 7 ++++++- apps/web/Dockerfile | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml index cc74175..02db889 100644 --- a/.github/workflows/deploy-tencent.yml +++ b/.github/workflows/deploy-tencent.yml @@ -94,7 +94,12 @@ jobs: fi - name: Build image from artifact - run: docker build --tag "meteortest-web:${GITHUB_SHA}" --file source/apps/web/Dockerfile source/apps/web + 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 env: diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 1813eb2..df77c9d 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,8 +1,9 @@ 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 +RUN npm ci --registry="${NPM_REGISTRY}" FROM dependencies AS builder From 1e67bc1623543ee9246b73f0ece354fb1d67d549 Mon Sep 17 00:00:00 2001 From: Connor Date: Sun, 12 Jul 2026 01:40:33 +0800 Subject: [PATCH 6/7] fix(deploy): isolate compose variables (#136) --- deploy/deploy-container.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/deploy-container.sh b/deploy/deploy-container.sh index defb212..212f0c2 100644 --- a/deploy/deploy-container.sh +++ b/deploy/deploy-container.sh @@ -34,7 +34,8 @@ compose() { local project="$1" local env_file="$2" shift 2 - docker compose --project-name "${project}" --env-file "${env_file}" --file "${compose_file}" "$@" + 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() { From 8b7de18f03dce0ca1b14c15f6bbeb7a290e53619 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:48:24 +0800 Subject: [PATCH 7/7] Prepare v0.1.3 release --- apps/web/package-lock.json | 4 ++-- apps/web/package.json | 2 +- docs/releases/v0.1.3.md | 29 +++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 docs/releases/v0.1.3.md 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/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 +```