From 4b9e115d459b24817727fc97106d6305e5212b45 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:29:07 +0800 Subject: [PATCH 01/10] Load configurable scenarios on mobile --- apps/mobile/app/(tabs)/home.tsx | 4 -- apps/mobile/app/(tabs)/session.tsx | 6 +- apps/mobile/src/App.tsx | 29 +++++++++- apps/mobile/src/AppShell.tsx | 5 +- apps/mobile/src/SessionContext.tsx | 2 + apps/mobile/src/runtimeScenarios.ts | 9 +++ apps/mobile/src/screens/HistoryScreen.tsx | 12 ++-- apps/mobile/src/screens/HomeScreen.tsx | 10 +--- apps/web/app/api/scenarios/route.ts | 6 +- apps/web/lib/server/scenarios.ts | 70 +++++++++++++++++++++++ docs/supabase-setup.md | 12 ++++ tests/configurable-scenarios.test.ts | 47 +++++++++++++++ 12 files changed, 184 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/src/runtimeScenarios.ts create mode 100644 apps/web/lib/server/scenarios.ts create mode 100644 tests/configurable-scenarios.test.ts diff --git a/apps/mobile/app/(tabs)/home.tsx b/apps/mobile/app/(tabs)/home.tsx index 0f226cd..8ba27dd 100644 --- a/apps/mobile/app/(tabs)/home.tsx +++ b/apps/mobile/app/(tabs)/home.tsx @@ -5,8 +5,6 @@ import { useRouter } from 'expo-router' -import { scenarios } from '@meteorvoice/shared' - import { HomeScreen } from '../../src/screens/HomeScreen' import { useSession } from '../../src/SessionContext' @@ -18,8 +16,6 @@ export default function HomeTab() { router.replace('/(tabs)/session')} /> ) diff --git a/apps/mobile/app/(tabs)/session.tsx b/apps/mobile/app/(tabs)/session.tsx index b2c1e6b..f379de5 100644 --- a/apps/mobile/app/(tabs)/session.tsx +++ b/apps/mobile/app/(tabs)/session.tsx @@ -19,13 +19,13 @@ import { SessionScreen } from '../../src/screens/SessionScreen' import { useSession } from '../../src/SessionContext' export default function SessionTab() { - const { tr, locale, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion } = useSession() + const { tr, locale, availableScenarios, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion } = useSession() const { scenario, accent } = useMemo(() => { - const s = scenarios.find(x => x.key === selectedScenarioKey) ?? scenarios[0] + const s = availableScenarios.find(x => x.key === selectedScenarioKey) ?? availableScenarios[0] ?? scenarios[0] const a = accentProfiles.find(x => x.key === selectedAccentKey) ?? accentProfiles[0] return { scenario: s, accent: a } - }, [selectedScenarioKey, selectedAccentKey]) + }, [availableScenarios, selectedScenarioKey, selectedAccentKey]) return ( (scenarios) const [selectedAccentKey, setSelectedAccentKey] = useState('american') const [scenarioSwitching, setScenarioSwitching] = useState(false) const [apiSessionId] = useState(null) @@ -232,8 +235,29 @@ function AppInner({ children }: { children?: React.ReactNode }) { return () => { cancelled = true } }, [api, applyTtsPreferences, auth.state, logMetric]) + useEffect(() => { + let cancelled = false + void api.listScenarios(locale) + .then(result => { + if (!cancelled) setAvailableScenarios(resolveRuntimeScenarios(result.scenarios)) + }) + .catch(error => { + if (!cancelled) setAvailableScenarios(scenarios) + logMetric('mobile_scenarios_load_error', { + message: error instanceof Error ? error.message : 'unknown', + }) + }) + return () => { cancelled = true } + }, [api, auth.state, locale, logMetric]) + // ─── Derived Values / 派生值 ─── - const scenario = useMemo(() => scenarios.find(s => s.key === selectedScenarioKey) ?? scenarios[0], [selectedScenarioKey]) + const scenario = useMemo( + () => availableScenarios.find(s => s.key === selectedScenarioKey) + ?? scenarios.find(s => s.key === selectedScenarioKey) + ?? availableScenarios[0] + ?? scenarios[0], + [availableScenarios, selectedScenarioKey], + ) const accent = useMemo(() => accentProfiles.find(a => a.key === selectedAccentKey) ?? accentProfiles[0], [selectedAccentKey]) // Voice profile accent override (for SettingsScreen voice selection) @@ -868,6 +892,7 @@ function AppInner({ children }: { children?: React.ReactNode }) { // ─── SessionContext Value / 会话上下文值 ─── const sessionContext = useMemo(() => ({ appVersion, applyTtsPreferences, auth, defaultApiBaseUrl, getAuthHeaders, handleUnauthorized, signOut, + availableScenarios, snapshot, messages, corrections: correctionHistory, summary, isSessionActive, status, busy, scenarioSwitching, locale, tr, @@ -877,7 +902,7 @@ function AppInner({ children }: { children?: React.ReactNode }) { audioUrl, api, startSession, endSession, playCorrection, selectScenario, setLocale, submitText: submitTurn, clearAudio, - }), [applyTtsPreferences, auth, clearAudio, getAuthHeaders, handleUnauthorized, signOut, snapshot, messages, correctionHistory, summary, isSessionActive, status, busy, scenarioSwitching, locale, tr, ttsProvider, ttsVoiceId, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion, audioUrl, api, startSession, endSession, playCorrection, selectScenario, setLocale, submitTurn, playbackQueue, setSelectedAccentKey, setTtsProvider, setTtsVoiceId, setTtsSpeed]) + }), [applyTtsPreferences, auth, availableScenarios, clearAudio, getAuthHeaders, handleUnauthorized, signOut, snapshot, messages, correctionHistory, summary, isSessionActive, status, busy, scenarioSwitching, locale, tr, ttsProvider, ttsVoiceId, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion, audioUrl, api, startSession, endSession, playCorrection, selectScenario, setLocale, submitTurn, playbackQueue, setSelectedAccentKey, setTtsProvider, setTtsVoiceId, setTtsSpeed]) // ─── Tab Selection / 标签选择 ─── const selectTab = useCallback((tab: Tab) => { diff --git a/apps/mobile/src/AppShell.tsx b/apps/mobile/src/AppShell.tsx index 04903cc..8776a5d 100644 --- a/apps/mobile/src/AppShell.tsx +++ b/apps/mobile/src/AppShell.tsx @@ -17,9 +17,6 @@ import type { Locale, TranslateFn, } from '@meteorvoice/shared' -import { - scenarios, -} from '@meteorvoice/shared' import type { SessionContextValue } from './SessionContext' import type { MobileAuthState } from './mobileAuth' @@ -132,7 +129,7 @@ export function AppShell({ - {activeTab === 'home' && setActiveTab('session')} />} + {activeTab === 'home' && setActiveTab('session')} />} {activeTab === 'session' && } {activeTab === 'history' && } {activeTab === 'settings' && } diff --git a/apps/mobile/src/SessionContext.tsx b/apps/mobile/src/SessionContext.tsx index 5302649..2cebc48 100644 --- a/apps/mobile/src/SessionContext.tsx +++ b/apps/mobile/src/SessionContext.tsx @@ -22,6 +22,7 @@ import type { ConversationMessage, ConversationResponse, Locale, + Scenario, TranslateFn, } from '@meteorvoice/shared' import type { MobileAuthState } from './mobileAuth' @@ -32,6 +33,7 @@ export interface SessionContextValue { applyTtsPreferences: (preferences: PreferencesResponse) => void auth: MobileAuthState audioUrl: string | null + availableScenarios: Scenario[] busy: boolean clearAudio: () => void corrections: ConversationResponse['corrections'] diff --git a/apps/mobile/src/runtimeScenarios.ts b/apps/mobile/src/runtimeScenarios.ts new file mode 100644 index 0000000..5d7fcfd --- /dev/null +++ b/apps/mobile/src/runtimeScenarios.ts @@ -0,0 +1,9 @@ +/** + * Mobile runtime scenario fallback. / 移动端运行时场景回退。 + */ +import type { Scenario } from '@meteorvoice/shared' +import { scenarios } from '@meteorvoice/shared' + +export function resolveRuntimeScenarios(remote: readonly Scenario[] | null | undefined): Scenario[] { + return remote?.length ? [...remote] : scenarios +} diff --git a/apps/mobile/src/screens/HistoryScreen.tsx b/apps/mobile/src/screens/HistoryScreen.tsx index b78a37f..513e4af 100644 --- a/apps/mobile/src/screens/HistoryScreen.tsx +++ b/apps/mobile/src/screens/HistoryScreen.tsx @@ -17,6 +17,7 @@ import { import type { Locale, + Scenario, TranslateFn, } from '@meteorvoice/shared' import type { @@ -27,12 +28,12 @@ import { accentProfiles, getAccentLabel, getScenarioLabel, - scenarios, } from '@meteorvoice/shared' import { useHistoryScreenState } from '../hooks/useHistoryScreenState' import type { HistoryAuthState } from '../historyRefresh' import { ContentState } from '../components/ContentState' +import { useSession } from '../SessionContext' import { useTheme } from '../ThemeProvider' interface Props { @@ -45,8 +46,8 @@ interface Props { refreshKey: number } -function scenarioLabel(entry: HistorySession, locale: Locale) { - const s = scenarios.find(x => x.key === (entry.scenario_key ?? entry.scenario)) +function scenarioLabel(entry: HistorySession, locale: Locale, availableScenarios: Scenario[]) { + const s = availableScenarios.find(x => x.key === (entry.scenario_key ?? entry.scenario)) return s ? getScenarioLabel(s, locale) : entry.scenario } @@ -56,6 +57,7 @@ function accentLabel(entry: HistorySession, locale: Locale) { } export function HistoryScreen({ tr, locale, api, authState, authUserId, handleUnauthorized, refreshKey }: Props) { + const { availableScenarios } = useSession() const { C } = useTheme() const { deleteSession, @@ -141,7 +143,7 @@ export function HistoryScreen({ tr, locale, api, authState, authUserId, handleUn setFilterScenario(null)} style={[styles.filterChip, filterScenario === null && styles.filterChipActive]}> {tr('history.filter_all')} - {scenarios.map(s => ( + {availableScenarios.map(s => ( setFilterScenario(s.key)} style={[styles.filterChip, filterScenario === s.key && styles.filterChipActive]}> {s.icon} {getScenarioLabel(s, locale)} @@ -174,7 +176,7 @@ export function HistoryScreen({ tr, locale, api, authState, authUserId, handleUn > - {scenarioLabel(item, locale)} + {scenarioLabel(item, locale, availableScenarios)} {item.date} · {accentLabel(item, locale)} diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index dec3e14..656e281 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -17,7 +17,6 @@ import type { Locale, TranslateFn, } from '@meteorvoice/shared' -import type { scenarios as ScenariosType } from '@meteorvoice/shared' import { getDifficultyLabel, getScenarioDescription, @@ -27,20 +26,17 @@ import { import { useSession } from '../SessionContext' import { useTheme } from '../ThemeProvider' -type Scenario = (typeof ScenariosType)[number] - interface Props { tr: TranslateFn locale: Locale - scenarios: Scenario[] onGoToSession: () => void } export function HomeScreen({ - tr, locale, scenarios, + tr, locale, onGoToSession, }: Props) { - const { selectedScenarioKey, isSessionActive, selectScenario, scenarioSwitching } = useSession() + const { availableScenarios, selectedScenarioKey, isSessionActive, selectScenario, scenarioSwitching } = useSession() const { C } = useTheme() async function handleScenario(key: string) { const shouldNavigate = await selectScenario(key) @@ -88,7 +84,7 @@ export function HomeScreen({ {tr('home.subtitle')} item.key} numColumns={2} columnWrapperStyle={styles.row} diff --git a/apps/web/app/api/scenarios/route.ts b/apps/web/app/api/scenarios/route.ts index c14d74e..f0b3dcb 100644 --- a/apps/web/app/api/scenarios/route.ts +++ b/apps/web/app/api/scenarios/route.ts @@ -5,7 +5,6 @@ import { getScenarioDescription, getScenarioLabel, normalizeLocale, - scenarios, } from '@meteorvoice/shared' import { @@ -13,6 +12,7 @@ import { jsonApiResult, jsonServerError, } from '@/lib/server/http' +import { listConfiguredScenarios } from '@/lib/server/scenarios' export async function GET(request: Request) { try { @@ -20,9 +20,10 @@ export async function GET(request: Request) { if (guard) return jsonApiResult(guard) const url = new URL(request.url) const locale = normalizeLocale(url.searchParams.get('locale')) + const configuredScenarios = await listConfiguredScenarios() return jsonApiResult({ - scenarios: scenarios.map(scenario => ({ + scenarios: configuredScenarios.map(scenario => ({ ...scenario, label: getScenarioLabel(scenario, locale), localized_description: getScenarioDescription(scenario, locale), @@ -32,4 +33,3 @@ export async function GET(request: Request) { return jsonServerError(error, 'Failed to load scenarios') } } - diff --git a/apps/web/lib/server/scenarios.ts b/apps/web/lib/server/scenarios.ts new file mode 100644 index 0000000..1a5293e --- /dev/null +++ b/apps/web/lib/server/scenarios.ts @@ -0,0 +1,70 @@ +/** + * Runtime scenario configuration backed by Supabase. / Supabase 驱动的运行时场景配置。 + */ +import type { Scenario } from '@meteorvoice/shared' +import { scenarios } from '@meteorvoice/shared' + +import { createClient } from '@/lib/supabase/server' + +export interface ScenarioRow { + key: string + name: string + name_zh: string | null + description: string | null + description_zh: string | null + difficulty: string + icon: string | null +} + +const legacyIcons: Record = { + briefcase: '💼', + building: '🏢', + coffee: '☕', + plane: '✈️', + utensils: '🍽️', +} + +function normalizeDifficulty(value: string): Scenario['difficulty'] { + if (value === 'intermediate' || value === 'advanced') return value + return 'beginner' +} + +export function mapScenarioRow(row: ScenarioRow): Scenario { + const description = row.description?.trim() || row.name + + return { + key: row.key, + name: row.name, + description, + labels: { + en: row.name, + zh: row.name_zh?.trim() || row.name, + }, + descriptions: { + en: description, + zh: row.description_zh?.trim() || description, + }, + difficulty: normalizeDifficulty(row.difficulty), + icon: legacyIcons[row.icon ?? ''] ?? (row.icon?.trim() || '💬'), + } +} + +export function resolveConfiguredScenarios(rows: ScenarioRow[] | null | undefined): Scenario[] { + if (!rows?.length) return scenarios + return rows.map(mapScenarioRow) +} + +export async function listConfiguredScenarios(): Promise { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return scenarios + + const { data, error } = await supabase + .from('scenarios') + .select('key,name,name_zh,description,description_zh,difficulty,icon') + .eq('enabled', true) + .order('created_at', { ascending: true }) + + if (error) return scenarios + return resolveConfiguredScenarios(data) +} diff --git a/docs/supabase-setup.md b/docs/supabase-setup.md index 5af995b..bba68cd 100644 --- a/docs/supabase-setup.md +++ b/docs/supabase-setup.md @@ -59,6 +59,18 @@ This gives you a single login surface with two formal account types: - Coach voice catalog rows are stored on `tts_voice_profiles` and readable by anon/authenticated clients through the server API. - Accent profiles and scenarios are readable by authenticated users. +## Scenario configuration + +The mobile app loads enabled rows from the `scenarios` table after sign-in. Update +`name`, `name_zh`, `description`, `description_zh`, `difficulty`, `icon`, or +`enabled` in the Supabase Table Editor to change the practice catalog without an +app rebuild. Keep at least one row enabled. `icon` may be an emoji; the original +seed values (`briefcase`, `plane`, `coffee`, `utensils`, and `building`) are also +mapped to their emoji equivalents. + +Signed-out clients and failed configuration requests use the shared built-in +catalog so the home screen remains available offline. + ## Coach Voice Profiles `tts_voice_profiles` is the editable catalog for all provider voices. Insert one row per voice. Use `provider_voice_id` for the value required by the provider API, and use `display_name`, `display_name_zh`, `gender`, `style`, `quality_tier`, `status`, and `expires_at` for UI and availability. diff --git a/tests/configurable-scenarios.test.ts b/tests/configurable-scenarios.test.ts new file mode 100644 index 0000000..82adc26 --- /dev/null +++ b/tests/configurable-scenarios.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { + mapScenarioRow, + resolveConfiguredScenarios, +} from '@/lib/server/scenarios' +import { scenarios } from '@meteorvoice/shared' +import { resolveRuntimeScenarios } from '../apps/mobile/src/runtimeScenarios' + +const configuredRow = { + key: 'business-trip', + name: 'Business Trip', + name_zh: '商务出差', + description: 'Practice business travel conversations', + description_zh: '练习商务出行对话', + difficulty: 'intermediate', + icon: 'briefcase', +} + +describe('configurable scenarios', () => { + it('maps Supabase rows into the shared scenario contract', () => { + expect(mapScenarioRow(configuredRow)).toEqual({ + key: 'business-trip', + name: 'Business Trip', + description: 'Practice business travel conversations', + labels: { en: 'Business Trip', zh: '商务出差' }, + descriptions: { + en: 'Practice business travel conversations', + zh: '练习商务出行对话', + }, + difficulty: 'intermediate', + icon: '💼', + }) + }) + + it('falls back to built-in scenarios when configuration is unavailable', () => { + expect(resolveConfiguredScenarios(null)).toBe(scenarios) + expect(resolveConfiguredScenarios([])).toBe(scenarios) + expect(resolveRuntimeScenarios([])).toBe(scenarios) + }) + + it('keeps a non-empty remote scenario list for mobile screens', () => { + const configured = resolveConfiguredScenarios([configuredRow]) + + expect(resolveRuntimeScenarios(configured)).toEqual(configured) + }) +}) From a87490ae28a56f7a24ea0b931cec644efb97f3de Mon Sep 17 00:00:00 2001 From: ConnorQi Date: Sun, 12 Jul 2026 00:17:30 +0800 Subject: [PATCH 02/10] Document Tencent Docker deployment --- docs/deployment-runbook.md | 2 + docs/index.md | 2 + docs/tencent-docker-deployment.md | 115 ++++++++++++++++++++++++ docs/tencent-docker-deployment.zh-CN.md | 115 ++++++++++++++++++++++++ 4 files changed, 234 insertions(+) create mode 100644 docs/tencent-docker-deployment.md create mode 100644 docs/tencent-docker-deployment.zh-CN.md diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index 5f5d7ef..f5b3076 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -2,6 +2,8 @@ 本文档记录 MeteorVoice monorepo 迁移后的 Web 部署方式。当前 Web app 位于 `apps/web`,Mobile 位于 `apps/mobile`,共享包位于 `packages/*`。 +腾讯云 Web/API 的 Docker + TCR 目标部署及 PM2 迁移步骤见 `docs/tencent-docker-deployment.md` 和 `docs/tencent-docker-deployment.zh-CN.md`。该文档在迁移验收完成前属于目标设计,不代表服务器已经切换到 Docker。 + ## Vercel 推荐配置 当前推荐让 Vercel 仍从仓库根目录安装和构建,因为 npm workspaces、`file:` 本地包和 lockfile 都在根目录。 diff --git a/docs/index.md b/docs/index.md index f3a056b..572b2ef 100644 --- a/docs/index.md +++ b/docs/index.md @@ -63,6 +63,8 @@ - v1.3.0 release notes,覆盖 ASR provider 层、mobile 语音稳定性、运行时反馈、API 防护和验证命令。 - `docs/deployment-runbook.md` - Monorepo 迁移后的 Vercel 部署、分支职责、环境变量和发布/回滚流程。 +- `docs/tencent-docker-deployment.md` / `docs/tencent-docker-deployment.zh-CN.md` + - 腾讯云 Web/API Docker + TCR 目标架构、PM2 平滑迁移、健康检查和回滚手册。 ## 已完成计划 diff --git a/docs/tencent-docker-deployment.md b/docs/tencent-docker-deployment.md new file mode 100644 index 0000000..17b647d --- /dev/null +++ b/docs/tencent-docker-deployment.md @@ -0,0 +1,115 @@ +# Tencent Docker Deployment + +This runbook defines the target Docker deployment for the MeteorVoice Web/API on the existing Tencent server. It covers the server component only; the iOS app, hosted Supabase project, and host Nginx remain outside Docker. + +> Status: target design. The active Tencent deployment still uses PM2 until the migration acceptance checklist in this document is complete. + +## Environment mapping + +| Branch | Environment | Domain | Host port | Compose project | Runtime env | +| --- | --- | --- | ---: | --- | --- | +| `main` | Preview | `mv-pre.jcmeteor.com` / `mv-pre-cn.jcmeteor.com` | `3101` | `meteorvoice-preview` | `/etc/meteorvoice/meteorvoice.env` | +| `release` | Production | `meteorvoice.jcmeteor.com` / `mv-cn.jcmeteor.com` | `3100` | `meteorvoice-production` | `/etc/meteorvoice/meteorvoice.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 runs lint, tests, mobile typecheck, and the Web production build. +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 added as aliases, but deployments MUST resolve to the SHA tag. +5. The repository-specific Tencent runner pulls the image and updates only the matching Compose project. +6. The runner waits for the container health check and verifies the public domain. +7. A failed health check MUST restore the previous image SHA. + +The server MUST NOT run `git fetch`, `npm ci`, or `next build` after migration. + +## Image contract + +- Proposed image: `//meteorvoice-web`. +- Build context: repository root, because the Web app consumes npm workspaces under `packages/*`. +- 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, and mobile/native build artifacts. + +## Configuration and secrets + +Real provider credentials remain in `/etc/meteorvoice/meteorvoice.env`, owned by root and readable only by the deployment/runtime account as required. Compose injects the file at container startup. + +GitHub stores only registry access and non-secret deployment metadata: + +- variables: TCR registry, namespace, image repository; +- secrets: TCR username/token or the equivalent short-lived credential; +- no Xunfei, DeepSeek, Supabase service-role, or other application secret is included in the image. + +## Compose requirements + +Preview and production MUST use separate Compose project names, containers, and networks. Each service MUST define: + +- `restart: unless-stopped`; +- a health check for the Web/API process; +- 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:3101` or `127.0.0.1:3100` host binding. + +Deployment metadata MAY live under `/srv/containers/meteorvoice/{preview,production}`. Application secrets MUST stay under `/etc/meteorvoice`. + +## 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 server runtime. +3. Start a shadow container on an unused localhost port and verify Web pages plus `/api/scenarios`, `/api/chat` request validation, and TTS request validation. +4. Switch only the relevant Nginx upstream to the shadow container and run public-domain checks. +5. Stop only the matching PM2 process. +6. Start the final Compose project on the existing `3101` or `3100` port and return Nginx to that port. +7. Observe logs and health before migrating the next environment. + +Do not run `pm2 kill`. Keep the PM2 definitions and source checkout until both environments have passed the observation window. + +## Routine deployment + +Routine deployment updates only one branch environment: + +1. resolve the new 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 Docker rollback, set the deployment metadata to the previous image SHA and run Compose pull/up again. Verify localhost and public health before closing the incident. + +During the first migration, if Docker cannot serve the environment: + +1. stop the failed Compose project; +2. restore the previous Nginx upstream if it changed; +3. restart only `meteorvoice` or `meteorvoice-release` in PM2; +4. verify the original port and public domain. + +## Acceptance checklist + +- CI builds the same commit that is tagged and deployed. +- No application secret exists in image history, build logs, or TCR metadata. +- Preview and production can deploy and roll back independently. +- Containers bind only to localhost. +- Nginx configuration passes `nginx -t` before reload. +- PM2 rollback is tested before its definitions are removed. +- Preview and production domains return HTTP 200. +- Auth, scenario loading, chat, Xunfei ASR/TTS, and mobile API compatibility are checked. +- Docker logs rotate and old images have a documented retention policy. + +## Related documentation + +- `docs/deployment-runbook.md`: branch and release responsibilities. +- `docs/release-manager.md`: release automation. +- `docs/tts-integration.md`: speech-provider runtime secrets. +- `docs/mobile-local-build-runbook.md`: iOS build and API endpoint behavior. diff --git a/docs/tencent-docker-deployment.zh-CN.md b/docs/tencent-docker-deployment.zh-CN.md new file mode 100644 index 0000000..0e1f3f8 --- /dev/null +++ b/docs/tencent-docker-deployment.zh-CN.md @@ -0,0 +1,115 @@ +# 腾讯云 Docker 部署 + +本文定义 MeteorVoice Web/API 在现有腾讯云服务器上的目标 Docker 部署方式。容器化范围只包括服务端 Web/API;iOS App、托管 Supabase 和宿主机 Nginx 不进入 Docker。 + +> 状态:目标设计。完成本文验收清单之前,腾讯云当前仍使用 PM2 部署。 + +## 环境映射 + +| 分支 | 环境 | 域名 | 宿主机端口 | Compose 项目 | 运行时环境文件 | +| --- | --- | --- | ---: | --- | --- | +| `main` | 预览 | `mv-pre.jcmeteor.com` / `mv-pre-cn.jcmeteor.com` | `3101` | `meteorvoice-preview` | `/etc/meteorvoice/meteorvoice.env` | +| `release` | 生产 | `meteorvoice.jcmeteor.com` / `mv-cn.jcmeteor.com` | `3100` | `meteorvoice-production` | `/etc/meteorvoice/meteorvoice.env` | + +Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0.1`。 + +## 目标交付流程 + +1. GitHub 托管 Runner 检出目标提交。 +2. CI 执行 lint、测试、Mobile typecheck 和 Web 生产构建。 +3. CI 构建多阶段 Next.js standalone 镜像并推送到腾讯云容器镜像服务(TCR)。 +4. 镜像使用不可变 commit SHA 标签;分支和版本号 MAY 作为别名,但部署 MUST 最终解析到 SHA 标签。 +5. MeteorVoice 专属腾讯 Runner 拉取镜像,只更新对应 Compose 项目。 +6. Runner 等待容器健康检查并验证公网域名。 +7. 健康检查失败时 MUST 恢复上一镜像 SHA。 + +迁移完成后,服务器 MUST 不再执行 `git fetch`、`npm ci` 或 `next build`。 + +## 镜像契约 + +- 建议镜像名:`//meteorvoice-web`。 +- 构建上下文:仓库根目录,因为 Web 依赖 `packages/*` npm workspaces。 +- Next.js MUST 使用 `output: 'standalone'`。 +- 运行阶段 MUST 只包含 standalone server、静态资源和必要 public 文件。 +- 运行进程 MUST 使用非 root 用户。 +- 密钥 MUST NOT 复制进镜像或通过 Docker build argument 传入。 +- `.dockerignore` MUST 排除 `.git`、`.env*`、本地构建输出、日志和 Mobile/native 构建产物。 + +## 配置与密钥 + +真实 provider 凭据继续保存在 `/etc/meteorvoice/meteorvoice.env`,由 root 持有,并按部署和运行所需设置最小读取权限。Compose 在容器启动时注入该文件。 + +GitHub 只保存镜像仓库访问凭据和非敏感部署元数据: + +- Variables:TCR registry、namespace、image repository; +- Secrets:TCR 用户名/Token 或等价的短期凭据; +- 讯飞、DeepSeek、Supabase service-role 等应用密钥不得进入镜像。 + +## Compose 要求 + +预览和生产 MUST 使用不同的 Compose 项目名、容器和网络。每个服务 MUST 定义: + +- `restart: unless-stopped`; +- Web/API 健康检查; +- JSON 日志轮转(`max-size: 10m`、`max-file: 3`); +- 适配当前 3.6 GiB 服务器的内存限制; +- 不可变镜像 SHA; +- `127.0.0.1:3101` 或 `127.0.0.1:3100` 绑定。 + +部署元数据 MAY 放在 `/srv/containers/meteorvoice/{preview,production}`;应用密钥 MUST 保留在 `/etc/meteorvoice`。 + +## 首次从 PM2 迁移 + +每次只迁移一个环境,必须先预览后生产。 + +1. 记录当前 Git commit、PM2 进程、Nginx 配置和公网健康结果。 +2. 构建并推送候选镜像,不改变服务器运行状态。 +3. 在未使用的 localhost 端口启动影子容器,验证 Web 页面以及 `/api/scenarios`、`/api/chat` 参数校验和 TTS 参数校验。 +4. 只将当前环境的 Nginx upstream 切到影子容器并执行公网检查。 +5. 只停止对应 PM2 进程。 +6. 在原 `3101` 或 `3100` 端口启动最终 Compose 项目,并将 Nginx 恢复指向该端口。 +7. 观察日志和健康状态后,再迁移下一个环境。 + +禁止执行 `pm2 kill`。两个环境完成观察期之前,保留 PM2 定义和服务器源码目录。 + +## 日常部署 + +日常部署只更新一个分支环境: + +1. 解析新的不可变镜像 SHA; +2. 记录当前运行 SHA; +3. 登录 TCR 并拉取镜像; +4. 更新对应 Compose 项目; +5. 等待容器健康; +6. 验证 localhost 端口和公网域名; +7. 保留上一 SHA 用于回滚。 + +## 回滚 + +普通 Docker 回滚应将部署元数据改回上一镜像 SHA,然后重新执行 Compose pull/up。关闭故障前必须同时验证 localhost 和公网健康。 + +首次迁移期间,如果 Docker 无法承载该环境: + +1. 停止失败的 Compose 项目; +2. 如 Nginx upstream 已变更,恢复旧配置; +3. 只重启 PM2 中的 `meteorvoice` 或 `meteorvoice-release`; +4. 验证原端口和公网域名。 + +## 验收清单 + +- CI 构建、标记和部署的是同一个 commit。 +- 镜像历史、构建日志和 TCR 元数据中不存在应用密钥。 +- 预览与生产可独立部署、独立回滚。 +- 容器只绑定 localhost。 +- reload 前 Nginx 配置通过 `nginx -t`。 +- 删除 PM2 定义前完成 PM2 回滚验证。 +- 预览和生产域名均返回 HTTP 200。 +- 完成 Auth、场景加载、Chat、讯飞 ASR/TTS 和 Mobile API 兼容验证。 +- Docker 日志已轮转,旧镜像有明确保留策略。 + +## 相关文档 + +- `docs/deployment-runbook.md`:分支和发布职责。 +- `docs/release-manager.md`:发布自动化。 +- `docs/tts-integration.md`:语音 provider 运行时密钥。 +- `docs/mobile-local-build-runbook.md`:iOS 构建和 API 端点行为。 From 69a90c3d9b3a9d86c1851dddf61a30a878864f44 Mon Sep 17 00:00:00 2001 From: ConnorQi Date: Sun, 12 Jul 2026 00:33:46 +0800 Subject: [PATCH 03/10] Containerize Tencent Web deployment --- .dockerignore | 18 ++++ .github/workflows/deploy-tencent.yml | 131 ++++++++++++++++-------- Dockerfile | 39 +++++++ apps/web/app/api/health/route.ts | 8 ++ apps/web/next.config.ts | 1 + deploy/deploy-container.sh | 102 ++++++++++++++++++ deploy/docker-compose.yml | 29 ++++++ docs/deployment-runbook.md | 2 +- docs/index.md | 2 +- docs/tencent-docker-deployment.md | 14 +-- docs/tencent-docker-deployment.zh-CN.md | 14 +-- 11 files changed, 299 insertions(+), 61 deletions(-) create mode 100644 .dockerignore create mode 100644 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4e92f99 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +.next +**/.next +node_modules +**/node_modules +.env +.env.* +!.env.example +!**/.env.example +*.log +coverage +playwright-report +test-results +apps/mobile/ios +apps/mobile/android +apps/mobile/.expo +docs diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml index 9b426b4..73200ea 100644 --- a/.github/workflows/deploy-tencent.yml +++ b/.github/workflows/deploy-tencent.yml @@ -1,9 +1,22 @@ -name: Deploy Tencent +name: Deploy Tencent Docker permissions: contents: read + packages: write on: + pull_request: + paths: + - .dockerignore + - Dockerfile + - deploy/** + - apps/web/next.config.ts + - apps/web/app/api/health/** + - package.json + - package-lock.json + - apps/web/package.json + - packages/** + - .github/workflows/deploy-tencent.yml push: branches: - main @@ -11,62 +24,98 @@ on: workflow_dispatch: concurrency: - group: deploy-tencent-${{ github.ref }} + group: deploy-tencent-docker-${{ github.ref }} cancel-in-progress: false +env: + IMAGE_NAME: ghcr.io/junchenmeteor/meteorvoice-web + jobs: + build: + name: Build Web/API image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Upload deployment manifest + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: meteorvoice-deployment-${{ github.sha }} + path: deploy + retention-days: 7 + deploy: - name: Deploy Web/API + name: Deploy Web/API container + if: github.event_name != 'pull_request' + needs: build runs-on: [self-hosted, linux, x64, tencent, meteorvoice] environment: tencent steps: + - name: Download deployment manifest + uses: actions/download-artifact@v4 + with: + name: meteorvoice-deployment-${{ github.sha }} + path: deployment + - name: Resolve target id: target shell: bash run: | if [ "${GITHUB_REF_NAME}" = "release" ]; then - echo "app_dir=/srv/meteorvoice-release" >> "$GITHUB_OUTPUT" - echo "pm2_name=meteorvoice-release" >> "$GITHUB_OUTPUT" + echo "environment=production" >> "$GITHUB_OUTPUT" echo "port=3100" >> "$GITHUB_OUTPUT" + echo "shadow_port=3110" >> "$GITHUB_OUTPUT" + echo "pm2_name=meteorvoice-release" >> "$GITHUB_OUTPUT" + echo "public_url=https://meteorvoice.jcmeteor.com/" >> "$GITHUB_OUTPUT" else - echo "app_dir=/srv/meteorvoice" >> "$GITHUB_OUTPUT" - echo "pm2_name=meteorvoice" >> "$GITHUB_OUTPUT" + echo "environment=preview" >> "$GITHUB_OUTPUT" echo "port=3101" >> "$GITHUB_OUTPUT" + echo "shadow_port=3111" >> "$GITHUB_OUTPUT" + echo "pm2_name=meteorvoice" >> "$GITHUB_OUTPUT" + echo "public_url=https://mv-pre.jcmeteor.com/" >> "$GITHUB_OUTPUT" fi + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Deploy - shell: bash + env: + APP_NAME: meteorvoice + ENVIRONMENT: ${{ steps.target.outputs.environment }} + HOST_PORT: ${{ steps.target.outputs.port }} + IMAGE_URI: ${{ env.IMAGE_NAME }}:${{ github.sha }} + PM2_NAME: ${{ steps.target.outputs.pm2_name }} + PUBLIC_URL: ${{ steps.target.outputs.public_url }} + RUNTIME_ENV_FILE: /etc/meteorvoice/meteorvoice.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 - } - - cd '${{ steps.target.outputs.app_dir }}' - retry 5 15 timeout 180s git fetch origin "$GITHUB_REF_NAME" - git checkout "$GITHUB_REF_NAME" - git reset --hard "origin/$GITHUB_REF_NAME" - retry 3 15 npm ci - set -a - . /etc/meteorvoice/meteorvoice.env - set +a - retry 2 15 npm run build - unset RUNNER_TRACKING_ID - PORT='${{ steps.target.outputs.port }}' HOSTNAME=127.0.0.1 \ - pm2 restart '${{ steps.target.outputs.pm2_name }}' --update-env || \ - PORT='${{ steps.target.outputs.port }}' HOSTNAME=127.0.0.1 \ - pm2 start npm --name '${{ steps.target.outputs.pm2_name }}' -- run start --workspace @meteorvoice/web -- --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 deployment/deploy-container.sh + deployment/deploy-container.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eafe03c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM node:24-bookworm-slim AS dependencies + +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY apps/web/package.json apps/web/package.json +COPY apps/mobile/package.json apps/mobile/package.json +COPY packages/api-client/package.json packages/api-client/package.json +COPY packages/session-core/package.json packages/session-core/package.json +COPY packages/shared/package.json packages/shared/package.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/apps/web/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public + +USER nextjs +EXPOSE 3000 + +CMD ["node", "apps/web/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..773e028 --- /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: 'meteorvoice-web', status: 'ok' }) +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 19e8d08..3727846 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; import path from "path"; const nextConfig: NextConfig = { + output: "standalone", transpilePackages: [ "@meteorvoice/api-client", "@meteorvoice/session-core", diff --git a/deploy/deploy-container.sh b/deploy/deploy-container.sh new file mode 100644 index 0000000..fb9445d --- /dev/null +++ b/deploy/deploy-container.sh @@ -0,0 +1,102 @@ +#!/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 + +docker pull "${IMAGE_URI}" + +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}" + docker pull "${previous_image}" || true + 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/deployment-runbook.md b/docs/deployment-runbook.md index f5b3076..54eb85a 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -2,7 +2,7 @@ 本文档记录 MeteorVoice monorepo 迁移后的 Web 部署方式。当前 Web app 位于 `apps/web`,Mobile 位于 `apps/mobile`,共享包位于 `packages/*`。 -腾讯云 Web/API 的 Docker + TCR 目标部署及 PM2 迁移步骤见 `docs/tencent-docker-deployment.md` 和 `docs/tencent-docker-deployment.zh-CN.md`。该文档在迁移验收完成前属于目标设计,不代表服务器已经切换到 Docker。 +腾讯云 Web/API 的 Docker + GHCR 部署及 PM2 迁移步骤见 `docs/tencent-docker-deployment.md` 和 `docs/tencent-docker-deployment.zh-CN.md`。该文档在迁移验收完成前属于目标设计,不代表服务器已经切换到 Docker。 ## Vercel 推荐配置 diff --git a/docs/index.md b/docs/index.md index 572b2ef..d80394a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,7 +64,7 @@ - `docs/deployment-runbook.md` - Monorepo 迁移后的 Vercel 部署、分支职责、环境变量和发布/回滚流程。 - `docs/tencent-docker-deployment.md` / `docs/tencent-docker-deployment.zh-CN.md` - - 腾讯云 Web/API Docker + TCR 目标架构、PM2 平滑迁移、健康检查和回滚手册。 + - 腾讯云 Web/API Docker + GHCR 部署架构、PM2 平滑迁移、健康检查和回滚手册。 ## 已完成计划 diff --git a/docs/tencent-docker-deployment.md b/docs/tencent-docker-deployment.md index 17b647d..5d82cbb 100644 --- a/docs/tencent-docker-deployment.md +++ b/docs/tencent-docker-deployment.md @@ -17,7 +17,7 @@ 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 runs lint, tests, mobile typecheck, and the Web production build. -3. CI builds a multi-stage Next.js standalone image and pushes it to Tencent Container Registry (TCR). +3. CI builds a multi-stage Next.js standalone image and pushes it to GitHub Container Registry (GHCR). 4. The image is tagged with an immutable commit SHA. Branch and release tags MAY be added as aliases, but deployments MUST resolve to the SHA tag. 5. The repository-specific Tencent runner pulls the image and updates only the matching Compose project. 6. The runner waits for the container health check and verifies the public domain. @@ -27,7 +27,7 @@ The server MUST NOT run `git fetch`, `npm ci`, or `next build` after migration. ## Image contract -- Proposed image: `//meteorvoice-web`. +- Image: `ghcr.io/junchenmeteor/meteorvoice-web:`. - Build context: repository root, because the Web app consumes npm workspaces under `packages/*`. - Next.js MUST use `output: 'standalone'`. - The runtime stage MUST contain only the standalone server, static assets, and required public files. @@ -39,11 +39,7 @@ The server MUST NOT run `git fetch`, `npm ci`, or `next build` after migration. Real provider credentials remain in `/etc/meteorvoice/meteorvoice.env`, owned by root and readable only by the deployment/runtime account as required. Compose injects the file at container startup. -GitHub stores only registry access and non-secret deployment metadata: - -- variables: TCR registry, namespace, image repository; -- secrets: TCR username/token or the equivalent short-lived credential; -- no Xunfei, DeepSeek, Supabase service-role, or other application secret is included in the image. +GitHub Actions uses the repository-scoped `GITHUB_TOKEN` to push and pull GHCR images. No long-lived registry password is required. Xunfei, DeepSeek, Supabase service-role, and other application secrets are not included in the image. ## Compose requirements @@ -78,7 +74,7 @@ Routine deployment updates only one branch environment: 1. resolve the new immutable image SHA; 2. record the currently running SHA; -3. authenticate to TCR and pull the image; +3. authenticate to GHCR with the workflow token and pull the image; 4. update the matching Compose project; 5. wait for container health; 6. verify the localhost port and public domain; @@ -98,7 +94,7 @@ During the first migration, if Docker cannot serve the environment: ## Acceptance checklist - CI builds the same commit that is tagged and deployed. -- No application secret exists in image history, build logs, or TCR metadata. +- No application secret exists in image history, build logs, or GHCR metadata. - Preview and production can deploy and roll back independently. - Containers bind only to localhost. - Nginx configuration passes `nginx -t` before reload. diff --git a/docs/tencent-docker-deployment.zh-CN.md b/docs/tencent-docker-deployment.zh-CN.md index 0e1f3f8..7bf5233 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 执行 lint、测试、Mobile typecheck 和 Web 生产构建。 -3. CI 构建多阶段 Next.js standalone 镜像并推送到腾讯云容器镜像服务(TCR)。 +3. CI 构建多阶段 Next.js standalone 镜像并推送到 GitHub Container Registry(GHCR)。 4. 镜像使用不可变 commit SHA 标签;分支和版本号 MAY 作为别名,但部署 MUST 最终解析到 SHA 标签。 5. MeteorVoice 专属腾讯 Runner 拉取镜像,只更新对应 Compose 项目。 6. Runner 等待容器健康检查并验证公网域名。 @@ -27,7 +27,7 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. ## 镜像契约 -- 建议镜像名:`//meteorvoice-web`。 +- 镜像名:`ghcr.io/junchenmeteor/meteorvoice-web:`。 - 构建上下文:仓库根目录,因为 Web 依赖 `packages/*` npm workspaces。 - Next.js MUST 使用 `output: 'standalone'`。 - 运行阶段 MUST 只包含 standalone server、静态资源和必要 public 文件。 @@ -39,11 +39,7 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. 真实 provider 凭据继续保存在 `/etc/meteorvoice/meteorvoice.env`,由 root 持有,并按部署和运行所需设置最小读取权限。Compose 在容器启动时注入该文件。 -GitHub 只保存镜像仓库访问凭据和非敏感部署元数据: - -- Variables:TCR registry、namespace、image repository; -- Secrets:TCR 用户名/Token 或等价的短期凭据; -- 讯飞、DeepSeek、Supabase service-role 等应用密钥不得进入镜像。 +GitHub Actions 使用仓库范围的 `GITHUB_TOKEN` 推送和拉取 GHCR 镜像,不需要长期镜像仓库密码。讯飞、DeepSeek、Supabase service-role 等应用密钥不得进入镜像。 ## Compose 要求 @@ -78,7 +74,7 @@ GitHub 只保存镜像仓库访问凭据和非敏感部署元数据: 1. 解析新的不可变镜像 SHA; 2. 记录当前运行 SHA; -3. 登录 TCR 并拉取镜像; +3. 使用 workflow token 登录 GHCR 并拉取镜像; 4. 更新对应 Compose 项目; 5. 等待容器健康; 6. 验证 localhost 端口和公网域名; @@ -98,7 +94,7 @@ GitHub 只保存镜像仓库访问凭据和非敏感部署元数据: ## 验收清单 - CI 构建、标记和部署的是同一个 commit。 -- 镜像历史、构建日志和 TCR 元数据中不存在应用密钥。 +- 镜像历史、构建日志和 GHCR 元数据中不存在应用密钥。 - 预览与生产可独立部署、独立回滚。 - 容器只绑定 localhost。 - reload 前 Nginx 配置通过 `nginx -t`。 From 6e6028250343a4cf107dea4ee1df1a24e9e29191 Mon Sep 17 00:00:00 2001 From: ConnorQi Date: Sun, 12 Jul 2026 00:50:02 +0800 Subject: [PATCH 04/10] Deliver Docker image as workflow artifact --- .github/workflows/deploy-tencent.yml | 27 +++++++++---------------- deploy/deploy-container.sh | 6 ++++-- docs/deployment-runbook.md | 2 +- docs/index.md | 2 +- docs/tencent-docker-deployment.md | 14 ++++++------- docs/tencent-docker-deployment.zh-CN.md | 14 ++++++------- 6 files changed, 29 insertions(+), 36 deletions(-) diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml index 73200ea..b77f3bd 100644 --- a/.github/workflows/deploy-tencent.yml +++ b/.github/workflows/deploy-tencent.yml @@ -2,7 +2,6 @@ name: Deploy Tencent Docker permissions: contents: read - packages: write on: pull_request: @@ -28,7 +27,7 @@ concurrency: cancel-in-progress: false env: - IMAGE_NAME: ghcr.io/junchenmeteor/meteorvoice-web + IMAGE_NAME: meteorvoice-web jobs: build: @@ -41,25 +40,21 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to GHCR - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build image uses: docker/build-push-action@v6 with: context: . file: Dockerfile platforms: linux/amd64 - push: ${{ github.event_name != 'pull_request' }} + outputs: type=docker,dest=${{ runner.temp }}/meteorvoice-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}/meteorvoice-image.tar" > deploy/meteorvoice-image.tar.gz + - name: Upload deployment manifest if: github.event_name != 'pull_request' uses: actions/upload-artifact@v4 @@ -99,19 +94,15 @@ jobs: echo "public_url=https://mv-pre.jcmeteor.com/" >> "$GITHUB_OUTPUT" fi - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Load image artifact + run: gzip -dc deployment/meteorvoice-image.tar.gz | docker load - name: Deploy env: APP_NAME: meteorvoice ENVIRONMENT: ${{ steps.target.outputs.environment }} HOST_PORT: ${{ steps.target.outputs.port }} - IMAGE_URI: ${{ env.IMAGE_NAME }}:${{ github.sha }} + IMAGE_URI: meteorvoice-web:${{ github.sha }} PM2_NAME: ${{ steps.target.outputs.pm2_name }} PUBLIC_URL: ${{ steps.target.outputs.public_url }} RUNTIME_ENV_FILE: /etc/meteorvoice/meteorvoice.env diff --git a/deploy/deploy-container.sh b/deploy/deploy-container.sh index fb9445d..defb212 100644 --- a/deploy/deploy-container.sh +++ b/deploy/deploy-container.sh @@ -58,7 +58,10 @@ if [ -n "${current_container}" ]; then previous_image="$(docker inspect --format '{{.Config.Image}}' "${current_container}")" fi -docker pull "${IMAGE_URI}" +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)" @@ -90,7 +93,6 @@ else if [ -n "${previous_image}" ]; then echo "Deployment failed; restoring ${previous_image}" >&2 write_deployment_env "${deployment_env}" "${previous_image}" "${HOST_PORT}" - docker pull "${previous_image}" || true compose "${project_name}" "${deployment_env}" up -d --wait --wait-timeout 180 wait_for_health "${HOST_PORT}" "${PUBLIC_URL}" fi diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index 54eb85a..a7a6f37 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -2,7 +2,7 @@ 本文档记录 MeteorVoice monorepo 迁移后的 Web 部署方式。当前 Web app 位于 `apps/web`,Mobile 位于 `apps/mobile`,共享包位于 `packages/*`。 -腾讯云 Web/API 的 Docker + GHCR 部署及 PM2 迁移步骤见 `docs/tencent-docker-deployment.md` 和 `docs/tencent-docker-deployment.zh-CN.md`。该文档在迁移验收完成前属于目标设计,不代表服务器已经切换到 Docker。 +腾讯云 Web/API 的 Docker + Actions Artifact 部署及 PM2 迁移步骤见 `docs/tencent-docker-deployment.md` 和 `docs/tencent-docker-deployment.zh-CN.md`。该文档在迁移验收完成前属于目标设计,不代表服务器已经切换到 Docker。 ## Vercel 推荐配置 diff --git a/docs/index.md b/docs/index.md index d80394a..17668f6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,7 +64,7 @@ - `docs/deployment-runbook.md` - Monorepo 迁移后的 Vercel 部署、分支职责、环境变量和发布/回滚流程。 - `docs/tencent-docker-deployment.md` / `docs/tencent-docker-deployment.zh-CN.md` - - 腾讯云 Web/API Docker + GHCR 部署架构、PM2 平滑迁移、健康检查和回滚手册。 + - 腾讯云 Web/API Docker + Actions Artifact 部署架构、PM2 平滑迁移、健康检查和回滚手册。 ## 已完成计划 diff --git a/docs/tencent-docker-deployment.md b/docs/tencent-docker-deployment.md index 5d82cbb..93036fe 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 runs lint, tests, mobile typecheck, and the Web production build. -3. CI builds a multi-stage Next.js standalone image and pushes it to GitHub Container Registry (GHCR). +3. CI exports the multi-stage Next.js standalone image as a compressed GitHub Actions artifact. 4. The image is tagged with an immutable commit SHA. Branch and release tags MAY be added as aliases, but deployments MUST resolve to the SHA tag. -5. The repository-specific Tencent runner pulls the image and updates only the matching Compose project. +5. The repository-specific Tencent runner downloads the artifact, loads the immutable image into Docker, and updates only the matching Compose project. 6. The runner waits for the container health check and verifies the public domain. 7. A failed health check MUST restore the previous image SHA. @@ -27,7 +27,7 @@ The server MUST NOT run `git fetch`, `npm ci`, or `next build` after migration. ## Image contract -- Image: `ghcr.io/junchenmeteor/meteorvoice-web:`. +- Image tag: `meteorvoice-web:`. - Build context: repository root, because the Web app consumes npm workspaces under `packages/*`. - Next.js MUST use `output: 'standalone'`. - The runtime stage MUST contain only the standalone server, static assets, and required public files. @@ -39,7 +39,7 @@ The server MUST NOT run `git fetch`, `npm ci`, or `next build` after migration. Real provider credentials remain in `/etc/meteorvoice/meteorvoice.env`, owned by root and readable only by the deployment/runtime account as required. Compose injects the file at container startup. -GitHub Actions uses the repository-scoped `GITHUB_TOKEN` to push and pull GHCR images. No long-lived registry password is required. Xunfei, DeepSeek, Supabase service-role, and other application secrets are not included in the image. +GitHub Actions stores the compressed image for seven days and transfers it through the workflow artifact service. No container-registry password is required. Xunfei, DeepSeek, Supabase service-role, and other application secrets are not included in the image artifact. ## Compose requirements @@ -74,8 +74,8 @@ Routine deployment updates only one branch environment: 1. resolve the new immutable image SHA; 2. record the currently running SHA; -3. authenticate to GHCR with the workflow token 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. @@ -94,7 +94,7 @@ During the first migration, if Docker cannot serve the environment: ## Acceptance checklist - CI builds the same commit that is tagged and deployed. -- No application secret exists in image history, build logs, or GHCR metadata. +- No application secret exists in image history, build logs, or artifact metadata. - Preview and production can deploy and roll back independently. - Containers bind only to localhost. - Nginx configuration passes `nginx -t` before reload. diff --git a/docs/tencent-docker-deployment.zh-CN.md b/docs/tencent-docker-deployment.zh-CN.md index 7bf5233..b67d295 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 执行 lint、测试、Mobile typecheck 和 Web 生产构建。 -3. CI 构建多阶段 Next.js standalone 镜像并推送到 GitHub Container Registry(GHCR)。 +3. CI 将多阶段 Next.js standalone 镜像导出为压缩的 GitHub Actions Artifact。 4. 镜像使用不可变 commit SHA 标签;分支和版本号 MAY 作为别名,但部署 MUST 最终解析到 SHA 标签。 -5. MeteorVoice 专属腾讯 Runner 拉取镜像,只更新对应 Compose 项目。 +5. MeteorVoice 专属腾讯 Runner 下载 Artifact、将不可变镜像加载到 Docker,并只更新对应 Compose 项目。 6. Runner 等待容器健康检查并验证公网域名。 7. 健康检查失败时 MUST 恢复上一镜像 SHA。 @@ -27,7 +27,7 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. ## 镜像契约 -- 镜像名:`ghcr.io/junchenmeteor/meteorvoice-web:`。 +- 镜像标签:`meteorvoice-web:`。 - 构建上下文:仓库根目录,因为 Web 依赖 `packages/*` npm workspaces。 - Next.js MUST 使用 `output: 'standalone'`。 - 运行阶段 MUST 只包含 standalone server、静态资源和必要 public 文件。 @@ -39,7 +39,7 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. 真实 provider 凭据继续保存在 `/etc/meteorvoice/meteorvoice.env`,由 root 持有,并按部署和运行所需设置最小读取权限。Compose 在容器启动时注入该文件。 -GitHub Actions 使用仓库范围的 `GITHUB_TOKEN` 推送和拉取 GHCR 镜像,不需要长期镜像仓库密码。讯飞、DeepSeek、Supabase service-role 等应用密钥不得进入镜像。 +GitHub Actions 保留压缩镜像七天,并通过 workflow Artifact 服务传输,不需要镜像仓库密码。讯飞、DeepSeek、Supabase service-role 等应用密钥不得进入镜像 Artifact。 ## Compose 要求 @@ -74,8 +74,8 @@ GitHub Actions 使用仓库范围的 `GITHUB_TOKEN` 推送和拉取 GHCR 镜像 1. 解析新的不可变镜像 SHA; 2. 记录当前运行 SHA; -3. 使用 workflow token 登录 GHCR 并拉取镜像; -4. 更新对应 Compose 项目; +3. 下载并加载镜像 Artifact; +4. 使用本地不可变镜像更新对应 Compose 项目; 5. 等待容器健康; 6. 验证 localhost 端口和公网域名; 7. 保留上一 SHA 用于回滚。 @@ -94,7 +94,7 @@ GitHub Actions 使用仓库范围的 `GITHUB_TOKEN` 推送和拉取 GHCR 镜像 ## 验收清单 - CI 构建、标记和部署的是同一个 commit。 -- 镜像历史、构建日志和 GHCR 元数据中不存在应用密钥。 +- 镜像历史、构建日志和 Artifact 元数据中不存在应用密钥。 - 预览与生产可独立部署、独立回滚。 - 容器只绑定 localhost。 - reload 前 Nginx 配置通过 `nginx -t`。 From 960ab9f9a01b69b45b63d8a35bb489dfeee88f14 Mon Sep 17 00:00:00 2001 From: Connor Date: Sun, 12 Jul 2026 01:14:21 +0800 Subject: [PATCH 05/10] fix(deploy): upload image artifacts directly (#368) --- .github/workflows/deploy-tencent.yml | 30 +++++++++++++++++++++++-- deploy/receive-image-upload.sh | 27 ++++++++++++++++++++++ docs/deployment-runbook.md | 2 +- docs/index.md | 2 +- docs/tencent-docker-deployment.md | 6 ++--- docs/tencent-docker-deployment.zh-CN.md | 4 ++-- 6 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 deploy/receive-image-upload.sh diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml index b77f3bd..4c1bbcf 100644 --- a/.github/workflows/deploy-tencent.yml +++ b/.github/workflows/deploy-tencent.yml @@ -33,6 +33,7 @@ jobs: build: name: Build Web/API image runs-on: ubuntu-latest + environment: tencent steps: - name: Checkout uses: actions/checkout@v4 @@ -55,12 +56,33 @@ jobs: if: github.event_name != 'pull_request' run: gzip -1 < "${RUNNER_TEMP}/meteorvoice-image.tar" > deploy/meteorvoice-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/meteorvoice-image.tar.gz + rm -f deploy/meteorvoice-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: meteorvoice-deployment-${{ github.sha }} - path: deploy + path: | + deploy/docker-compose.yml + deploy/deploy-container.sh retention-days: 7 deploy: @@ -95,7 +117,7 @@ jobs: fi - name: Load image artifact - run: gzip -dc deployment/meteorvoice-image.tar.gz | docker load + run: gzip -dc "/srv/deploy-inbox/meteorvoice/${GITHUB_SHA}.tar.gz" | docker load - name: Deploy env: @@ -110,3 +132,7 @@ jobs: run: | chmod +x deployment/deploy-container.sh deployment/deploy-container.sh + + - name: Remove uploaded image artifact + if: success() + run: rm -f "/srv/deploy-inbox/meteorvoice/${GITHUB_SHA}.tar.gz" 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/deployment-runbook.md b/docs/deployment-runbook.md index a7a6f37..6ccb5ce 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -2,7 +2,7 @@ 本文档记录 MeteorVoice monorepo 迁移后的 Web 部署方式。当前 Web app 位于 `apps/web`,Mobile 位于 `apps/mobile`,共享包位于 `packages/*`。 -腾讯云 Web/API 的 Docker + Actions Artifact 部署及 PM2 迁移步骤见 `docs/tencent-docker-deployment.md` 和 `docs/tencent-docker-deployment.zh-CN.md`。该文档在迁移验收完成前属于目标设计,不代表服务器已经切换到 Docker。 +腾讯云 Web/API 的 Docker 镜像制品部署及 PM2 迁移步骤见 `docs/tencent-docker-deployment.md` 和 `docs/tencent-docker-deployment.zh-CN.md`。该文档在迁移验收完成前属于目标设计,不代表服务器已经切换到 Docker。 ## Vercel 推荐配置 diff --git a/docs/index.md b/docs/index.md index 17668f6..d8dad06 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,7 +64,7 @@ - `docs/deployment-runbook.md` - Monorepo 迁移后的 Vercel 部署、分支职责、环境变量和发布/回滚流程。 - `docs/tencent-docker-deployment.md` / `docs/tencent-docker-deployment.zh-CN.md` - - 腾讯云 Web/API Docker + Actions Artifact 部署架构、PM2 平滑迁移、健康检查和回滚手册。 + - 腾讯云 Web/API Docker 镜像制品部署架构、PM2 平滑迁移、健康检查和回滚手册。 ## 已完成计划 diff --git a/docs/tencent-docker-deployment.md b/docs/tencent-docker-deployment.md index 93036fe..d512970 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 runs lint, tests, mobile typecheck, and the Web production build. -3. CI exports the multi-stage Next.js standalone image as a compressed GitHub Actions artifact. +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 added as aliases, but deployments MUST resolve to the SHA tag. -5. The repository-specific Tencent runner downloads the artifact, loads the immutable image into Docker, and updates only the matching Compose project. +5. The repository-specific Tencent runner loads the uploaded artifact into Docker and updates only the matching Compose project. 6. The runner waits for the container health check and verifies the public domain. 7. A failed health check MUST restore the previous image SHA. @@ -39,7 +39,7 @@ The server MUST NOT run `git fetch`, `npm ci`, or `next build` after migration. Real provider credentials remain in `/etc/meteorvoice/meteorvoice.env`, owned by root and readable only by the deployment/runtime account as required. Compose injects the file at container startup. -GitHub Actions stores the compressed image for seven days and transfers it through the workflow artifact service. No container-registry password is required. Xunfei, DeepSeek, Supabase service-role, and other application secrets are not included in the image artifact. +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. Xunfei, DeepSeek, Supabase service-role, and other application secrets are not included in the image artifact. ## Compose requirements diff --git a/docs/tencent-docker-deployment.zh-CN.md b/docs/tencent-docker-deployment.zh-CN.md index b67d295..432b144 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 执行 lint、测试、Mobile typecheck 和 Web 生产构建。 -3. CI 将多阶段 Next.js standalone 镜像导出为压缩的 GitHub Actions Artifact。 +3. CI 将多阶段 Next.js standalone 镜像导出为压缩的 Docker 镜像制品。 4. 镜像使用不可变 commit SHA 标签;分支和版本号 MAY 作为别名,但部署 MUST 最终解析到 SHA 标签。 5. MeteorVoice 专属腾讯 Runner 下载 Artifact、将不可变镜像加载到 Docker,并只更新对应 Compose 项目。 6. Runner 等待容器健康检查并验证公网域名。 @@ -39,7 +39,7 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. 真实 provider 凭据继续保存在 `/etc/meteorvoice/meteorvoice.env`,由 root 持有,并按部署和运行所需设置最小读取权限。Compose 在容器启动时注入该文件。 -GitHub Actions 保留压缩镜像七天,并通过 workflow Artifact 服务传输,不需要镜像仓库密码。讯飞、DeepSeek、Supabase service-role 等应用密钥不得进入镜像 Artifact。 +GitHub 托管 Runner 使用只允许写入指定目录的 SSH 密钥,将压缩镜像直传到腾讯云制品收件箱;自托管 Runner 只负责加载和部署。该通道不需要镜像仓库密码,上传账号也不能获得交互式 Shell。讯飞、DeepSeek、Supabase service-role 等应用密钥不得进入镜像制品。 ## Compose 要求 From f9335dc3ae8317a794e9728e3167bb5a1149c59a Mon Sep 17 00:00:00 2001 From: Connor Date: Sun, 12 Jul 2026 01:22:17 +0800 Subject: [PATCH 06/10] fix(deploy): build from source artifacts (#370) --- .github/workflows/deploy-tencent.yml | 58 ++++++++----------------- deploy/receive-image-upload.sh | 27 ------------ docs/deployment-runbook.md | 2 +- docs/index.md | 2 +- docs/tencent-docker-deployment.md | 8 ++-- docs/tencent-docker-deployment.zh-CN.md | 6 +-- 6 files changed, 26 insertions(+), 77 deletions(-) delete mode 100644 deploy/receive-image-upload.sh diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml index 4c1bbcf..0a8d410 100644 --- a/.github/workflows/deploy-tencent.yml +++ b/.github/workflows/deploy-tencent.yml @@ -31,9 +31,8 @@ env: jobs: build: - name: Build Web/API image + name: Validate deployment image runs-on: ubuntu-latest - environment: tencent steps: - name: Checkout uses: actions/checkout@v4 @@ -42,47 +41,28 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Build image + if: github.event_name == 'pull_request' uses: docker/build-push-action@v6 with: context: . file: Dockerfile platforms: linux/amd64 - outputs: type=docker,dest=${{ runner.temp }}/meteorvoice-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}/meteorvoice-image.tar" > deploy/meteorvoice-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/meteorvoice-image.tar.gz - rm -f deploy/meteorvoice-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: meteorvoice-deployment-${{ github.sha }} + name: meteorvoice-source-${{ github.sha }} path: | - deploy/docker-compose.yml - deploy/deploy-container.sh + . + !.git + !node_modules + !apps/**/node_modules + !packages/**/node_modules + !apps/**/.next retention-days: 7 deploy: @@ -92,11 +72,11 @@ jobs: runs-on: [self-hosted, linux, x64, tencent, meteorvoice] environment: tencent steps: - - name: Download deployment manifest + - name: Download validated source artifact uses: actions/download-artifact@v4 with: - name: meteorvoice-deployment-${{ github.sha }} - path: deployment + name: meteorvoice-source-${{ github.sha }} + path: source - name: Resolve target id: target @@ -116,8 +96,8 @@ jobs: echo "public_url=https://mv-pre.jcmeteor.com/" >> "$GITHUB_OUTPUT" fi - - name: Load image artifact - run: gzip -dc "/srv/deploy-inbox/meteorvoice/${GITHUB_SHA}.tar.gz" | docker load + - name: Build image from artifact + run: docker build --tag "meteorvoice-web:${GITHUB_SHA}" --file source/Dockerfile source - name: Deploy env: @@ -130,9 +110,5 @@ jobs: RUNTIME_ENV_FILE: /etc/meteorvoice/meteorvoice.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/meteorvoice/${GITHUB_SHA}.tar.gz" + chmod +x source/deploy/deploy-container.sh + source/deploy/deploy-container.sh 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/deployment-runbook.md b/docs/deployment-runbook.md index 6ccb5ce..dffb8ec 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -2,7 +2,7 @@ 本文档记录 MeteorVoice monorepo 迁移后的 Web 部署方式。当前 Web app 位于 `apps/web`,Mobile 位于 `apps/mobile`,共享包位于 `packages/*`。 -腾讯云 Web/API 的 Docker 镜像制品部署及 PM2 迁移步骤见 `docs/tencent-docker-deployment.md` 和 `docs/tencent-docker-deployment.zh-CN.md`。该文档在迁移验收完成前属于目标设计,不代表服务器已经切换到 Docker。 +腾讯云 Web/API 的 Docker + 源码 Artifact 部署及 PM2 迁移步骤见 `docs/tencent-docker-deployment.md` 和 `docs/tencent-docker-deployment.zh-CN.md`。该文档在迁移验收完成前属于目标设计,不代表服务器已经切换到 Docker。 ## Vercel 推荐配置 diff --git a/docs/index.md b/docs/index.md index d8dad06..18de480 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,7 +64,7 @@ - `docs/deployment-runbook.md` - Monorepo 迁移后的 Vercel 部署、分支职责、环境变量和发布/回滚流程。 - `docs/tencent-docker-deployment.md` / `docs/tencent-docker-deployment.zh-CN.md` - - 腾讯云 Web/API Docker 镜像制品部署架构、PM2 平滑迁移、健康检查和回滚手册。 + - 腾讯云 Web/API Docker + 源码 Artifact 部署架构、PM2 平滑迁移、健康检查和回滚手册。 ## 已完成计划 diff --git a/docs/tencent-docker-deployment.md b/docs/tencent-docker-deployment.md index d512970..4f8d835 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 runs lint, tests, mobile typecheck, and the Web production build. -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 added as aliases, but deployments MUST resolve to the SHA tag. -5. The repository-specific Tencent runner loads the uploaded artifact into Docker and updates only the matching Compose project. +5. The repository-specific 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 the container health check and verifies the public domain. 7. A failed health check MUST restore the previous image SHA. @@ -39,7 +39,7 @@ The server MUST NOT run `git fetch`, `npm ci`, or `next build` after migration. Real provider credentials remain in `/etc/meteorvoice/meteorvoice.env`, owned by root and readable only by the deployment/runtime account as required. 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. Xunfei, DeepSeek, Supabase service-role, and other application secrets are not included in the image artifact. +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. Xunfei, DeepSeek, Supabase service-role, and other application secrets are not included in the source artifact or image. ## Compose requirements @@ -74,7 +74,7 @@ Routine deployment updates only one branch environment: 1. resolve the new 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 432b144..1ffe0b2 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 执行 lint、测试、Mobile typecheck 和 Web 生产构建。 -3. CI 将多阶段 Next.js standalone 镜像导出为压缩的 Docker 镜像制品。 +3. CI 在 PR 中验证 Docker 构建;合并后将精简源码作为 GitHub Actions Artifact 上传。 4. 镜像使用不可变 commit SHA 标签;分支和版本号 MAY 作为别名,但部署 MUST 最终解析到 SHA 标签。 5. MeteorVoice 专属腾讯 Runner 下载 Artifact、将不可变镜像加载到 Docker,并只更新对应 Compose 项目。 6. Runner 等待容器健康检查并验证公网域名。 @@ -39,7 +39,7 @@ Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0. 真实 provider 凭据继续保存在 `/etc/meteorvoice/meteorvoice.env`,由 root 持有,并按部署和运行所需设置最小读取权限。Compose 在容器启动时注入该文件。 -GitHub 托管 Runner 使用只允许写入指定目录的 SSH 密钥,将压缩镜像直传到腾讯云制品收件箱;自托管 Runner 只负责加载和部署。该通道不需要镜像仓库密码,上传账号也不能获得交互式 Shell。讯飞、DeepSeek、Supabase service-role 等应用密钥不得进入镜像制品。 +自托管 Runner 下载经验证的源码 Artifact,利用服务器 Docker 层缓存构建并部署,不拉取 Git 仓库,也不需要镜像仓库密码。讯飞、DeepSeek、Supabase service-role 等应用密钥不得进入源码 Artifact 或镜像。 ## Compose 要求 @@ -74,7 +74,7 @@ GitHub 托管 Runner 使用只允许写入指定目录的 SSH 密钥,将压缩 1. 解析新的不可变镜像 SHA; 2. 记录当前运行 SHA; -3. 下载并加载镜像 Artifact; +3. 下载源码 Artifact,并利用 Docker 层缓存构建镜像; 4. 使用本地不可变镜像更新对应 Compose 项目; 5. 等待容器健康; 6. 验证 localhost 端口和公网域名; From f0cf47ffde550c425936346f879222b82b737059 Mon Sep 17 00:00:00 2001 From: Connor Date: Sun, 12 Jul 2026 01:35:29 +0800 Subject: [PATCH 07/10] fix(deploy): use regional npm registry (#372) --- .github/workflows/deploy-tencent.yml | 7 ++++++- Dockerfile | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml index 0a8d410..83caa38 100644 --- a/.github/workflows/deploy-tencent.yml +++ b/.github/workflows/deploy-tencent.yml @@ -97,7 +97,12 @@ jobs: fi - name: Build image from artifact - run: docker build --tag "meteorvoice-web:${GITHUB_SHA}" --file source/Dockerfile source + run: >- + docker build + --build-arg NPM_REGISTRY=https://registry.npmmirror.com + --tag "meteorvoice-web:${GITHUB_SHA}" + --file source/Dockerfile + source - name: Deploy env: diff --git a/Dockerfile b/Dockerfile index eafe03c..c658ef4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,8 @@ FROM node:24-bookworm-slim AS dependencies WORKDIR /app +ARG NPM_REGISTRY=https://registry.npmjs.org + COPY package.json package-lock.json ./ COPY apps/web/package.json apps/web/package.json COPY apps/mobile/package.json apps/mobile/package.json @@ -9,7 +11,7 @@ COPY packages/api-client/package.json packages/api-client/package.json COPY packages/session-core/package.json packages/session-core/package.json COPY packages/shared/package.json packages/shared/package.json -RUN npm ci +RUN npm ci --registry="${NPM_REGISTRY}" FROM dependencies AS builder From a3f9193ebffcf9b2ecb315141320f61348ee8922 Mon Sep 17 00:00:00 2001 From: Connor Date: Sun, 12 Jul 2026 01:41:59 +0800 Subject: [PATCH 08/10] fix(deploy): isolate compose variables (#374) --- 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 e0c2d31cb599da2a1bb2acf69c449b7477ca5998 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:23 +0800 Subject: [PATCH 09/10] Prepare v1.4.2 release --- apps/mobile/app.json | 6 ++-- .../ios/MeteorVoice.xcodeproj/project.pbxproj | 8 ++--- apps/mobile/package.json | 2 +- apps/web/package.json | 2 +- docs/releases/v1.4.2.md | 30 +++++++++++++++++++ package-lock.json | 8 ++--- package.json | 2 +- 7 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 docs/releases/v1.4.2.md diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 0926705..dff7cee 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -3,7 +3,7 @@ "name": "MeteorVoice", "slug": "meteorvoice-mobile", "owner": "junchenmeteors-team", - "version": "1.4.1", + "version": "1.4.2", "orientation": "portrait", "scheme": "meteorvoice", "userInterfaceStyle": "automatic", @@ -11,7 +11,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "com.jcmeteor.meteorvoice", - "buildNumber": "2026071102", + "buildNumber": "2026071103", "deploymentTarget": "18.0", "infoPlist": { "NSMicrophoneUsageDescription": "MeteorVoice uses the microphone for voice practice sessions.", @@ -26,7 +26,7 @@ }, "android": { "package": "com.jcmeteor.meteorvoice", - "versionCode": 2026071102, + "versionCode": 2026071103, "permissions": [ "android.permission.RECORD_AUDIO", "android.permission.MODIFY_AUDIO_SETTINGS", diff --git a/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj b/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj index 2758e6a..2d893b5 100644 --- a/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj @@ -338,7 +338,7 @@ CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2026071102; + CURRENT_PROJECT_VERSION = 2026071103; DEVELOPMENT_TEAM = VY69DPYH3T; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -351,7 +351,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.1; + MARKETING_VERSION = 1.4.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -376,7 +376,7 @@ CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2026071102; + CURRENT_PROJECT_VERSION = 2026071103; DEVELOPMENT_TEAM = VY69DPYH3T; INFOPLIST_FILE = MeteorVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; @@ -384,7 +384,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.1; + MARKETING_VERSION = 1.4.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 4f5b568..cc75f93 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@meteorvoice/mobile", - "version": "1.4.1", + "version": "1.4.2", "private": true, "main": "index.ts", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index 5125878..aae58ea 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@meteorvoice/web", - "version": "1.4.1", + "version": "1.4.2", "private": true, "scripts": { "dev": "next dev", diff --git a/docs/releases/v1.4.2.md b/docs/releases/v1.4.2.md new file mode 100644 index 0000000..e4b0a4b --- /dev/null +++ b/docs/releases/v1.4.2.md @@ -0,0 +1,30 @@ +# Release Notes + +Release focus: production promotion for MeteorVoice 1.4.2. + +## Highlights + +- Promoted validated main branch changes to the production release branch. +- Updated Web and mobile package versions to `1.4.2`. +- Published GitHub Release tag `v1.4.2`. + +## Deployment + +- Production branch: `release` +- Preview branch: `main` +- Production URL: `https://meteorvoice.jcmeteor.com/` +- Preview URL: `https://mv-pre.jcmeteor.com/` + +## Versioning + +- Web version: `1.4.2` +- Mobile version: `1.4.2` +- iOS build number: `2026071103` +- Android version code: `2026071103` +- Release tag: `v1.4.2` + +## Validation + +```bash +npm test +``` diff --git a/package-lock.json b/package-lock.json index bf469f3..4bd74db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meteorvoice", - "version": "1.4.1", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meteorvoice", - "version": "1.4.1", + "version": "1.4.2", "workspaces": [ "packages/*", "apps/*" @@ -57,7 +57,7 @@ }, "apps/mobile": { "name": "@meteorvoice/mobile", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@meteorvoice/api-client": "file:../../packages/api-client", "@meteorvoice/session-core": "file:../../packages/session-core", @@ -869,7 +869,7 @@ }, "apps/web": { "name": "@meteorvoice/web", - "version": "1.4.1" + "version": "1.4.2" }, "node_modules/@ai-sdk/deepseek": { "version": "2.0.35", diff --git a/package.json b/package.json index 6968de8..a302dad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meteorvoice", - "version": "1.4.1", + "version": "1.4.2", "private": true, "workspaces": [ "packages/*", From 3a8d54bc75b13b32526fd7113a10a101ff8d08b1 Mon Sep 17 00:00:00 2001 From: Connor Date: Sun, 12 Jul 2026 01:57:39 +0800 Subject: [PATCH 10/10] fix(release): track Docker deploy workflow (#380) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/release-manager.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release-manager.mjs b/scripts/release-manager.mjs index e03b440..8f44e7a 100644 --- a/scripts/release-manager.mjs +++ b/scripts/release-manager.mjs @@ -24,7 +24,7 @@ const config = { releaseDoc: (version) => `docs/releases/v${version}.md`, validation: ['npm test'], releaseUrls: ['https://meteorvoice.jcmeteor.com/', 'https://mv-pre.jcmeteor.com/'], - deployWorkflow: 'Deploy Tencent', + deployWorkflow: 'Deploy Tencent Docker', } const args = process.argv.slice(2)