# API Docker:main → Staging 机(Repository secrets: STAGING_*),Tag v*.*.* → Prod 机(PROD_*) # 在 Repo → Settings → Secrets and variables → Actions 中配置,无需 GitHub Environments。 # 命名:STAGING_SSH_HOST / STAGING_SSH_USER / STAGING_SSH_PRIVATE_KEY / STAGING_SSH_PORT / STAGING_DEPLOY_PATH # 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。 # # 旧库 pg_dump 一次性迁入当前 schema:见 workflow「Legacy DB migrate (one-shot)」(手动运行,非每次构建)。 # # 发布策略: # - merge / push 到 main:构建并部署到 Staging 机;使用仓库中的 api/.env.staging,上传后切换为运行时 .env # - 手动创建并推送 tag vMAJOR.MINOR.PATCH:构建并部署到 Production;使用仓库中的 api/.env.production,上传后切换为运行时 .env # # 注意:paths 过滤在 tag push 时按「被指向的 commit」判断;若该 commit 未改 api/ 与本 workflow,不会触发。 # 此时可用 workflow_dispatch 选择对应 tag/ref 手动部署。 name: Docker Build and Deploy on: push: branches: - main tags: - 'v*.*.*' paths: - 'api/**' - '.github/workflows/**' workflow_dispatch: inputs: branch: description: '部署 ref(分支名或 tag,如 main / v1.0.0);留空则使用当前运行所选 ref' required: false type: string default: '' concurrency: group: docker-api-${{ github.ref }} cancel-in-progress: false env: IMAGE_NAME: lifecho-api REGISTRY: crpi-u2903xccyzd6nqnc.cn-shanghai.personal.cr.aliyuncs.com REGISTRY_NAMESPACE: huaga FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: build-and-push: name: Build and Push Docker Image runs-on: ubuntu-latest permissions: contents: read steps: - name: Checkout code uses: actions/checkout@v5 with: ref: ${{ github.event.inputs.branch || github.ref }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Alibaba Cloud Container Registry env: REGISTRY: ${{ env.REGISTRY }} USERNAME: ${{ secrets.ALIYUN_CR_USERNAME }} PASSWORD: ${{ secrets.ALIYUN_CR_PASSWORD }} run: | echo "正在登录到阿里云容器镜像服务..." echo "Registry: $REGISTRY" echo "Username: $USERNAME" echo "Password length: ${#PASSWORD}" # 使用 printf 确保密码正确传递(包括特殊字符) printf '%s\n' "$PASSWORD" | docker login "$REGISTRY" --username="$USERNAME" --password-stdin echo "✅ 登录成功!" - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.REGISTRY_NAMESPACE }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,prefix=sha- type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: ./api file: ./api/Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max deploy: name: Deploy to Remote Server runs-on: ubuntu-latest needs: 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 - name: Ensure production SSH secret is set if: steps.deploy_target.outputs.target == 'prod' env: PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }} run: | if [ -z "$PROD_SSH_PRIVATE_KEY" ]; then echo "::error::PROD_SSH_PRIVATE_KEY 未配置或为空,无法部署生产。请在 Repository secrets 中设置 PROD_SSH_*。" exit 1 fi - name: Ensure staging SSH secret is set if: steps.deploy_target.outputs.target != 'prod' env: STAGING_SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} run: | if [ -z "$STAGING_SSH_PRIVATE_KEY" ]; then echo "::error::STAGING_SSH_PRIVATE_KEY 未配置或为空,无法部署 staging。请在 Repository secrets 中设置 STAGING_SSH_*。" exit 1 fi # 勿用 `prod && PROD_KEY || STAGING_KEY`:PROD 为空时会错误回退到 staging 密钥,导致连生产机报 Permission denied。 - name: Set up SSH (production) if: steps.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' 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 { echo "SSH_HOST=${{ secrets.PROD_SSH_HOST }}" echo "SSH_USER=${{ secrets.PROD_SSH_USER }}" echo "SSH_PORT=${{ secrets.PROD_SSH_PORT || '22' }}" echo "COMPOSE_DIR=${{ secrets.PROD_DEPLOY_PATH || '/opt/life-echo' }}" } >> "$GITHUB_ENV" else { echo "SSH_HOST=${{ secrets.STAGING_SSH_HOST }}" echo "SSH_USER=${{ secrets.STAGING_SSH_USER }}" echo "SSH_PORT=${{ secrets.STAGING_SSH_PORT || '22' }}" echo "COMPOSE_DIR=${{ secrets.STAGING_DEPLOY_PATH || '/opt/life-echo' }}" } >> "$GITHUB_ENV" fi - name: Add server to known hosts run: | 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 }} REGISTRY: ${{ env.REGISTRY }} ALIYUN_CR_USERNAME: ${{ secrets.ALIYUN_CR_USERNAME }} ALIYUN_CR_PASSWORD: ${{ secrets.ALIYUN_CR_PASSWORD }} run: | set -euo pipefail echo "准备候选版本..." echo "镜像标签: $IMAGE_TAG" echo "部署目录: $COMPOSE_DIR/api" echo "$ALIYUN_CR_PASSWORD" | ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \ "docker login $REGISTRY --username=$ALIYUN_CR_USERNAME --password-stdin" ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" " set -euo pipefail mkdir -p '$COMPOSE_DIR/api' mkdir -p '$COMPOSE_DIR/api/backups' 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 ENV_SRC="api/.env.production" else ENV_SRC="api/.env.staging" fi if [ ! -f "$ENV_SRC" ]; then echo "::error::缺少 $ENV_SRC,无法部署。" exit 1 fi if grep -Eq '=(your_|replace_with_|\\.{3}$)' "$ENV_SRC"; then echo "::error::$ENV_SRC 仍包含占位符值,请先完善环境文件。" exit 1 fi if grep -Eq '^DATABASE_URL=.*@localhost:' "$ENV_SRC" || grep -Eq '^REDIS_URL=redis://localhost' "$ENV_SRC"; then echo "::error::$ENV_SRC 包含 localhost 数据库或 Redis 地址,容器内将无法连接。" exit 1 fi if grep -Eq '^DATABASE_URL=.*@postgresql:' "$ENV_SRC"; then echo "::error::$ENV_SRC 仍引用过期主机名 postgresql;当前 compose 服务名应为 postgres。" exit 1 fi echo "上传候选 compose 与环境文件..." scp -P "$SSH_PORT" ./api/docker-compose.yml "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/docker-compose.candidate.yml" scp -P "$SSH_PORT" "$ENV_SRC" "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/.env.candidate" ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" " set -euo pipefail cd '$COMPOSE_DIR/api' echo '拉取候选镜像: $IMAGE_TAG' docker pull '$IMAGE_TAG' sed -i.tmp 's|image:.*lifecho-api.*|image: $IMAGE_TAG|g' docker-compose.candidate.yml sed -i.tmp 's|image:.*life-echo-api.*|image: $IMAGE_TAG|g' docker-compose.candidate.yml rm -f docker-compose.candidate.yml.tmp 2>/dev/null || true " - name: Promote candidate release env: COMPOSE_FILE: docker-compose.yml run: | set -euo pipefail echo "切换线上版本,容器启动时将自动执行 Alembic..." ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" " set -euo pipefail cd '$COMPOSE_DIR/api' if [ -f '$COMPOSE_FILE' ]; then cp '$COMPOSE_FILE' '${COMPOSE_FILE}.predeploy' fi if [ -f '.env.production' ]; then cp '.env.production' '.env.production.predeploy' fi if [ -f '.env' ]; then cp '.env' '.env.predeploy' fi mv 'docker-compose.candidate.yml' '$COMPOSE_FILE' mv '.env.candidate' '.env' if ! docker compose -f '$COMPOSE_FILE' up -d --remove-orphans; then echo 'docker compose up 失败,输出 api 状态与最近日志...' docker compose -f '$COMPOSE_FILE' ps || true API_CID=\$(docker compose -f '$COMPOSE_FILE' ps -q api || true) if [ -n \"\$API_CID\" ]; then docker inspect -f '{{json .State}}' \"\$API_CID\" || true fi docker compose -f '$COMPOSE_FILE' logs --tail=120 api || true docker compose -f '$COMPOSE_FILE' logs --tail=80 celery-worker || true exit 1 fi echo '等待服务启动...' sleep 20 docker image prune -f || true docker compose -f '$COMPOSE_FILE' ps " - name: Verify deployment run: | echo "验证部署状态..." ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" " set -euo pipefail cd '$COMPOSE_DIR/api' docker compose ps API_CID=\$(docker compose ps -q api) if [ -z \"\$API_CID\" ]; then echo '未找到 api 容器' docker compose logs --tail=80 api || true exit 1 fi API_HEALTH='' for _ in \$(seq 1 24); do API_HEALTH=\$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \"\$API_CID\") echo \"api health: \$API_HEALTH\" if [ \"\$API_HEALTH\" = 'healthy' ]; then break fi sleep 5 done if [ \"\$API_HEALTH\" != 'healthy' ]; then echo 'api 容器未在预期时间内变为 healthy' docker inspect -f '{{json .State}}' \"\$API_CID\" || true docker compose logs --tail=80 api || true exit 1 fi docker compose logs --tail=50 api "