From 175784292da147f906f2d887d5b27e23c4b42dd0 Mon Sep 17 00:00:00 2001 From: Kevin Date: Sat, 9 May 2026 16:16:48 +0800 Subject: [PATCH] implement staging workflow --- .github/workflows/README.md | 8 +- .github/workflows/SETUP.md | 13 ++- .github/workflows/app-expo-deploy.yml | 47 ++++++++- .github/workflows/docker-build-deploy.yml | 99 ++++++++++--------- api/.env.staging | 3 + api/docker-compose.yml | 6 +- app-expo/.env.staging | 4 +- app-expo/app.config.ts | 6 ++ .../plugins/withAndroidCleartextTraffic.js | 32 ++++++ 9 files changed, 157 insertions(+), 61 deletions(-) create mode 100644 app-expo/plugins/withAndroidCleartextTraffic.js 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;