diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index 7cdf159..a18996c 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -5,7 +5,8 @@
- **工作流文件**: [docker-build-deploy.yml](docker-build-deploy.yml)
- **测试 job**:在构建镜像前于 `api/` 下执行 `uv sync --dev` 与 `pytest`。
- **Secrets**:预发 `STAGING_*`、生产 `PROD_*`、镜像 `ALIYUN_CR_*` — 详见 [SETUP.md](SETUP.md)。
-- **分支 / Tag**:`main` → 预发;语义化 tag `v*.*.*` → 生产;路径过滤为 `api/**` 与本 workflow。
+- **分支 / Tag**:`main` → Staging 服务器;语义化 tag `v*.*.*` → Production 服务器;路径过滤为 `api/**` 与本 workflow。
+- **手动补跑**:`workflow_dispatch` 仅支持 `main` / `master`(Staging)或 `vMAJOR.MINOR.PATCH` tag(Production)。其它 ref 会在测试与构建前失败。
头部注释与 `docker-build-deploy.yml` 内说明为最新权威描述。
@@ -15,4 +16,7 @@
## App Expo Deploy
-见仓库 `docs/` 下相关说明(若存在)。
+- **工作流文件**:[app-expo-deploy.yml](app-expo-deploy.yml)
+- **自动触发**:`main` → `stage`,使用 `app-expo/.env.staging` 构建 APK artifact;`v*.*.*` tag → `prod`,使用 `app-expo/.env.production` 并创建 GitHub Release。
+- **手动触发**:`dev` 可用于内部测试包;`stage` 只允许在 `main` / `master` 上补跑;`prod` 需要选择 `vMAJOR.MINOR.PATCH` tag,或在 `main` / `master` 上填写语义化 `version`。
+- **产物规则**:Staging APK 仅上传为 GitHub Actions artifact;Production APK 才创建正式 GitHub Release。
diff --git a/.github/workflows/SETUP.md b/.github/workflows/SETUP.md
index e61a9dd..c94b6d4 100644
--- a/.github/workflows/SETUP.md
+++ b/.github/workflows/SETUP.md
@@ -19,16 +19,25 @@
| `ALIYUN_CR_USERNAME` | 阿里云 ACR 用户名 |
| `ALIYUN_CR_PASSWORD` | 阿里云 ACR 密码 |
-> **Tag 部署**:推送 `v*.*.*`(如 `v1.2.0`)时使用 `PROD_*`。**main 分支推送**使用 `STAGING_*`。
+> **Staging 服务器**:`main` 分支发布使用 `STAGING_*`,用于部署到独立的预发服务器。
+> **Production 服务器**:推送 `v*.*.*`(如 `v1.2.0`)时使用 `PROD_*`。
+
+旧的无前缀 `SSH_HOST` / `SSH_USER` / `SSH_PRIVATE_KEY` / `SSH_PORT` / `DEPLOY_PATH` 指向生产机,当前 release workflow 不再读取它们;不要把这些值复制成 `STAGING_*`。Staging 需要填写另一台预发服务器自己的 `STAGING_*`。
## 触发条件
- `push` 到 `main`:改动了 `api/**` 或 `.github/workflows/**` 时,先跑 **API tests**(`uv sync --dev` + `pytest`),再构建镜像并部署预发。
- `push` tag `v*.*.*`:同上路径过滤;部署生产。
-- **workflow_dispatch**:可选手动指定 ref。
+- **workflow_dispatch**:仅用于补跑 `main` / `master`(Staging)或 `vMAJOR.MINOR.PATCH` tag(Production);其它 ref 会直接失败,避免把任意分支部署到预发或生产。
仓库内需存在 **`api/.env.staging`** / **`api/.env.production`**(供部署 job 校验与上传);勿将真实密钥提交到公开分支。
+## App Expo Release
+
+- `push` 到 `main`:构建 Staging APK,执行 `node scripts/use-env.js staging`,产物上传为 GitHub Actions artifact。
+- `push` tag `v*.*.*`:构建 Production APK,执行 `node scripts/use-env.js production`,并创建 GitHub Release。
+- 手动 `workflow_dispatch`:`stage` 只允许在 `main` / `master` 上补跑;`prod` 需要选择 `vMAJOR.MINOR.PATCH` tag,或在 `main` / `master` 上填写语义化 `version`。
+
## 本地验证 SSH
```bash
diff --git a/.github/workflows/app-expo-deploy.yml b/.github/workflows/app-expo-deploy.yml
index 9f3542f..6fa11d1 100644
--- a/.github/workflows/app-expo-deploy.yml
+++ b/.github/workflows/app-expo-deploy.yml
@@ -11,7 +11,10 @@
# push main → stage → node scripts/use-env.js staging → .env.staging
# push v*.*.* → prod → node scripts/use-env.js production → .env.production
#
-# 手动触发 workflow_dispatch:可选 dev / stage / prod(dev 用 .env.development,便于打内部测试包)
+# 手动触发 workflow_dispatch:
+# - dev:内部测试包,使用 .env.development
+# - stage:仅用于 main / master 补跑 Staging release,使用 .env.staging
+# - prod:用于 vMAJOR.MINOR.PATCH tag,或在 main / master 上填写 version 后发正式 Release
#
# Repository secrets(与 android-release.yml 共用同一套即可):
# ANDROID_KEYSTORE_BASE64 / ANDROID_STORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD
@@ -28,7 +31,7 @@ on:
workflow_dispatch:
inputs:
environment:
- description: '部署环境'
+ description: '部署环境(stage 请在 main 上补跑;prod 请使用 v tag 或在 main 上填写 version)'
required: true
type: choice
options:
@@ -67,15 +70,49 @@ jobs:
- name: Determine environment
id: env
run: |
- if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
- echo "env=prod"
- elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
+ if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "env=${{ github.event.inputs.environment }}"
+ elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
+ echo "env=prod"
else
# push 到 main(本 workflow 仅监听 main 与 tag)
echo "env=stage"
fi >> $GITHUB_OUTPUT
+ - name: Validate manual release ref
+ if: github.event_name == 'workflow_dispatch'
+ run: |
+ ENVIRONMENT="${{ steps.env.outputs.env }}"
+ REF="${{ github.ref }}"
+ REF_NAME="${{ github.ref_name }}"
+ VERSION="${{ github.event.inputs.version }}"
+
+ case "$ENVIRONMENT" in
+ dev)
+ echo "dev 构建允许使用当前 ref: $REF_NAME"
+ ;;
+ stage)
+ if [ "$REF_NAME" != "main" ] && [ "$REF_NAME" != "master" ]; then
+ echo "::error::Staging release 只允许在 main / master 上手动补跑,当前 ref 为 '$REF_NAME'。"
+ exit 1
+ fi
+ ;;
+ prod)
+ if [[ "$REF" == refs/tags/v* && "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "使用 tag $REF_NAME 发正式 Release。"
+ elif { [ "$REF_NAME" = "main" ] || [ "$REF_NAME" = "master" ]; } && [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "使用 main / master 和显式版本 v$VERSION 发正式 Release。"
+ else
+ echo "::error::Production release 需要选择 vMAJOR.MINOR.PATCH tag,或在 main / master 上填写语义化 version。"
+ exit 1
+ fi
+ ;;
+ *)
+ echo "::error::未知部署环境 '$ENVIRONMENT'。"
+ exit 1
+ ;;
+ esac
+
- name: Set up Node.js
uses: actions/setup-node@v5
with:
diff --git a/.github/workflows/docker-build-deploy.yml b/.github/workflows/docker-build-deploy.yml
index b11ae6b..fbb9d92 100644
--- a/.github/workflows/docker-build-deploy.yml
+++ b/.github/workflows/docker-build-deploy.yml
@@ -4,8 +4,8 @@
# PROD_SSH_HOST / PROD_SSH_USER / PROD_SSH_PRIVATE_KEY / PROD_SSH_PORT / PROD_DEPLOY_PATH
# 阿里云镜像仍为仓库级:ALIYUN_CR_USERNAME / ALIYUN_CR_PASSWORD
#
-# 从旧版迁移:若仓库里仍是 SSH_HOST、SSH_PRIVATE_KEY、DEPLOY_PATH 等无前缀名称,
-# 请把「预发机」对应值迁移为 STAGING_*,「新生产机」填 PROD_*,并删除旧的无前缀 Secret。
+# 注意:旧的无前缀 SSH_HOST / SSH_PRIVATE_KEY / DEPLOY_PATH 指向生产机;本 workflow 不再读取它们。
+# Staging 必须使用另一台服务器对应的 STAGING_*,Production 使用 PROD_*。
#
# 旧库 pg_dump 一次性迁入当前 schema:见 workflow「Legacy DB migrate (one-shot)」(手动运行,非每次构建)。
#
@@ -14,7 +14,7 @@
# - 手动创建并推送 tag vMAJOR.MINOR.PATCH:构建并部署到 Production;使用仓库中的 api/.env.production,上传后切换为运行时 .env
#
# 注意:paths 过滤在 tag push 时按「被指向的 commit」判断;若该 commit 未改 api/ 与本 workflow,不会触发。
-# 此时可用 workflow_dispatch 选择对应 tag/ref 手动部署。
+# 此时可用 workflow_dispatch 补跑 main(Staging)或 vMAJOR.MINOR.PATCH tag(Production)。
name: Docker Build and Deploy
@@ -46,13 +46,46 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
+ resolve-deploy-target:
+ name: Resolve deploy target
+ runs-on: ubuntu-latest
+ outputs:
+ deploy_ref: ${{ steps.deploy_target.outputs.deploy_ref }}
+ image_tag: ${{ steps.deploy_target.outputs.image_tag }}
+ target: ${{ steps.deploy_target.outputs.target }}
+ steps:
+ - name: Determine deploy target
+ id: deploy_target
+ run: |
+ if [ -n "${{ github.event.inputs.branch }}" ]; then
+ REF_NAME="${{ github.event.inputs.branch }}"
+ else
+ REF_NAME="${{ github.ref_name }}"
+ fi
+
+ echo "deploy_ref=$REF_NAME" >> "$GITHUB_OUTPUT"
+
+ if [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "target=prod" >> "$GITHUB_OUTPUT"
+ echo "image_tag=${REF_NAME#v}" >> "$GITHUB_OUTPUT"
+ elif [ "$REF_NAME" = "main" ] || [ "$REF_NAME" = "master" ]; then
+ echo "target=staging" >> "$GITHUB_OUTPUT"
+ echo "image_tag=latest" >> "$GITHUB_OUTPUT"
+ else
+ echo "::error::不支持部署 ref '$REF_NAME'。Staging release 只允许 main,Production release 只允许 vMAJOR.MINOR.PATCH tag。"
+ exit 1
+ fi
+
test:
name: API tests
+ needs: resolve-deploy-target
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
+ with:
+ ref: ${{ needs.resolve-deploy-target.outputs.deploy_ref }}
- name: Install uv
uses: astral-sh/setup-uv@v5
@@ -67,7 +100,9 @@ jobs:
build-and-push:
name: Build and Push Docker Image
- needs: test
+ needs:
+ - resolve-deploy-target
+ - test
runs-on: ubuntu-latest
permissions:
contents: read
@@ -76,7 +111,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v5
with:
- ref: ${{ github.event.inputs.branch || github.ref }}
+ ref: ${{ needs.resolve-deploy-target.outputs.deploy_ref }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -108,6 +143,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
+ type=raw,value=${{ needs.resolve-deploy-target.outputs.image_tag }}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
@@ -124,31 +160,19 @@ jobs:
deploy:
name: Deploy to Remote Server
runs-on: ubuntu-latest
- needs: build-and-push
+ needs:
+ - resolve-deploy-target
+ - build-and-push
if: github.event_name != 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
- ref: ${{ github.event.inputs.branch || github.ref }}
-
- - name: Determine deploy target
- id: deploy_target
- run: |
- if [ -n "${{ github.event.inputs.branch }}" ]; then
- REF_NAME="${{ github.event.inputs.branch }}"
- else
- REF_NAME="${{ github.ref_name }}"
- fi
- if [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
- echo "target=prod" >> "$GITHUB_OUTPUT"
- else
- echo "target=staging" >> "$GITHUB_OUTPUT"
- fi
+ ref: ${{ needs.resolve-deploy-target.outputs.deploy_ref }}
- name: Ensure production SSH secret is set
- if: steps.deploy_target.outputs.target == 'prod'
+ if: needs.resolve-deploy-target.outputs.target == 'prod'
env:
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
run: |
@@ -158,7 +182,7 @@ jobs:
fi
- name: Ensure staging SSH secret is set
- if: steps.deploy_target.outputs.target != 'prod'
+ if: needs.resolve-deploy-target.outputs.target != 'prod'
env:
STAGING_SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
run: |
@@ -169,20 +193,20 @@ jobs:
# 勿用 `prod && PROD_KEY || STAGING_KEY`:PROD 为空时会错误回退到 staging 密钥,导致连生产机报 Permission denied。
- name: Set up SSH (production)
- if: steps.deploy_target.outputs.target == 'prod'
+ if: needs.resolve-deploy-target.outputs.target == 'prod'
uses: webfactory/ssh-agent@v0.9.1
with:
ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
- name: Set up SSH (staging)
- if: steps.deploy_target.outputs.target != 'prod'
+ if: needs.resolve-deploy-target.outputs.target != 'prod'
uses: webfactory/ssh-agent@v0.9.1
with:
ssh-private-key: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
- name: Export deploy connection env
run: |
- if [ "${{ steps.deploy_target.outputs.target }}" = "prod" ]; then
+ if [ "${{ needs.resolve-deploy-target.outputs.target }}" = "prod" ]; then
{
echo "SSH_HOST=${{ secrets.PROD_SSH_HOST }}"
echo "SSH_USER=${{ secrets.PROD_SSH_USER }}"
@@ -203,28 +227,9 @@ jobs:
mkdir -p ~/.ssh
ssh-keyscan -H -p "${SSH_PORT:-22}" "${SSH_HOST}" >> ~/.ssh/known_hosts
- - name: Determine image tag
- id: image_tag
- run: |
- # 与 docker/metadata-action 的 semver 标签一致:v1.2.3 → 镜像 :1.2.3
- if [ -n "${{ github.event.inputs.branch }}" ]; then
- REF_NAME="${{ github.event.inputs.branch }}"
- else
- REF_NAME="${{ github.ref_name }}"
- fi
- echo "deploy_ref=$REF_NAME" >> "$GITHUB_OUTPUT"
- if [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
- echo "tag=${REF_NAME#v}" >> "$GITHUB_OUTPUT"
- elif [ "$REF_NAME" == "main" ] || [ "$REF_NAME" == "master" ]; then
- echo "tag=latest" >> "$GITHUB_OUTPUT"
- else
- BRANCH_TAG=$(echo "$REF_NAME" | sed 's/\//-/g')
- echo "tag=$BRANCH_TAG" >> "$GITHUB_OUTPUT"
- fi
-
- name: Prepare remote candidate release
env:
- IMAGE_TAG: ${{ env.REGISTRY }}/${{ env.REGISTRY_NAMESPACE }}/${{ env.IMAGE_NAME }}:${{ steps.image_tag.outputs.tag }}
+ IMAGE_TAG: ${{ env.REGISTRY }}/${{ env.REGISTRY_NAMESPACE }}/${{ env.IMAGE_NAME }}:${{ needs.resolve-deploy-target.outputs.image_tag }}
REGISTRY: ${{ env.REGISTRY }}
ALIYUN_CR_USERNAME: ${{ secrets.ALIYUN_CR_USERNAME }}
ALIYUN_CR_PASSWORD: ${{ secrets.ALIYUN_CR_PASSWORD }}
@@ -244,7 +249,7 @@ jobs:
docker network inspect api_life-echo-network >/dev/null 2>&1 || docker network create api_life-echo-network
"
- if [ "${{ steps.deploy_target.outputs.target }}" = "prod" ]; then
+ if [ "${{ needs.resolve-deploy-target.outputs.target }}" = "prod" ]; then
ENV_SRC="api/.env.production"
else
ENV_SRC="api/.env.staging"
diff --git a/api/.env.staging b/api/.env.staging
index 7ed7a27..4758c2e 100644
--- a/api/.env.staging
+++ b/api/.env.staging
@@ -1,3 +1,6 @@
+LIFE_ECHO_API_HOST_BIND=0.0.0.0
+LIFE_ECHO_API_HOST_PORT=8000
+
# =============================================================================
# Life Echo API — staging(预发)
#
diff --git a/api/docker-compose.yml b/api/docker-compose.yml
index b3e6368..947f5df 100644
--- a/api/docker-compose.yml
+++ b/api/docker-compose.yml
@@ -56,10 +56,10 @@ services:
dockerfile: Dockerfile
image: life-echo-api:latest
container_name: life-echo-api-prod
- # 独立 Caddy(宿主机或其它 compose)经 HTTPS 反代;仅绑定本机回环,避免与机上其它项目端口直接对公网。
- # 若与 Cosmetic 等共用主机且 8000 已被占用,在 .env 中设置 LIFE_ECHO_API_HOST_PORT=其它端口并在 Caddyfile 中一致。
+ # 默认仅绑定本机回环,交给宿主机 Caddy/反代;staging 如需 IP:port 直连,可在 .env 设置 LIFE_ECHO_API_HOST_BIND=0.0.0.0。
+ # 若与 Cosmetic 等共用主机且 8000 已被占用,在 .env 中设置 LIFE_ECHO_API_HOST_PORT=其它端口并在 Caddyfile / app env 中一致。
ports:
- - "127.0.0.1:${LIFE_ECHO_API_HOST_PORT:-8000}:8000"
+ - "${LIFE_ECHO_API_HOST_BIND:-127.0.0.1}:${LIFE_ECHO_API_HOST_PORT:-8000}:8000"
env_file:
- .env
environment:
diff --git a/app-expo/.env.staging b/app-expo/.env.staging
index 33f59c6..b29f0c7 100644
--- a/app-expo/.env.staging
+++ b/app-expo/.env.staging
@@ -1,3 +1,3 @@
# 仅 API/WS 基址;TTS 每轮开关由运行时 WS payload 与服务端 ENABLE_TTS 控制(见 api/.env.example)。
-EXPO_PUBLIC_API_URL=https://staging.lifecho.worldsplats.com
-EXPO_PUBLIC_WS_URL=wss://staging.lifecho.worldsplats.com
+EXPO_PUBLIC_API_URL=http://1.15.29.57:8000
+EXPO_PUBLIC_WS_URL=ws://1.15.29.57:8000
diff --git a/app-expo/app.config.ts b/app-expo/app.config.ts
index 567058d..2755118 100644
--- a/app-expo/app.config.ts
+++ b/app-expo/app.config.ts
@@ -28,6 +28,8 @@ const LOCALES: Record = {
const SUPPORTED_LOCALES = ['zh', 'en'] as const;
const PRIMARY_LOCALE = process.env.EXPO_PUBLIC_PRIMARY_LOCALE ?? 'zh';
+const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? '';
+const ALLOW_ANDROID_CLEARTEXT_TRAFFIC = API_BASE_URL.startsWith('http://');
const PERMISSION_FALLBACKS: Record = {
microphone: 'Allow $(PRODUCT_NAME) to access your microphone.',
@@ -149,6 +151,10 @@ export default ({ config }: ConfigContext): ExpoConfig => {
plugins: [
// CI/local release: android/app/keystore.properties + store file → release signing; -PversionName/-PversionCode
'./plugins/withAndroidReleaseSigning',
+ [
+ './plugins/withAndroidCleartextTraffic',
+ { enabled: ALLOW_ANDROID_CLEARTEXT_TRAFFIC },
+ ],
'expo-router',
[
'expo-splash-screen',
diff --git a/app-expo/plugins/withAndroidCleartextTraffic.js b/app-expo/plugins/withAndroidCleartextTraffic.js
new file mode 100644
index 0000000..b36af79
--- /dev/null
+++ b/app-expo/plugins/withAndroidCleartextTraffic.js
@@ -0,0 +1,32 @@
+// @ts-check
+/**
+ * Toggle Android cleartext HTTP traffic from Expo env.
+ *
+ * Staging may use an IP:port HTTP endpoint while production remains HTTPS.
+ */
+const { withAndroidManifest } = require('@expo/config-plugins');
+
+/**
+ * @param {import('expo/config').ExpoConfig} config
+ * @param {{ enabled?: boolean }} props
+ */
+function withAndroidCleartextTraffic(config, props = {}) {
+ return withAndroidManifest(config, (mod) => {
+ const mainApplication = mod.modResults.manifest.application?.[0];
+
+ if (!mainApplication) {
+ throw new Error(
+ '[withAndroidCleartextTraffic] Main application not found in AndroidManifest.xml.',
+ );
+ }
+
+ mainApplication.$ = mainApplication.$ ?? {};
+ mainApplication.$['android:usesCleartextTraffic'] = props.enabled
+ ? 'true'
+ : 'false';
+
+ return mod;
+ });
+}
+
+module.exports = withAndroidCleartextTraffic;