355 lines
14 KiB
YAML
355 lines
14 KiB
YAML
# 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:
|
||
test:
|
||
name: API tests
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- uses: actions/checkout@v5
|
||
|
||
- name: Install uv
|
||
uses: astral-sh/setup-uv@v5
|
||
with:
|
||
version: "0.7.3"
|
||
|
||
- name: Sync deps and run pytest
|
||
working-directory: api
|
||
run: |
|
||
uv sync --dev
|
||
uv run pytest --tb=short -q
|
||
|
||
build-and-push:
|
||
name: Build and Push Docker Image
|
||
needs: test
|
||
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
|
||
# 仅检查「有效配置行」:跳过整行注释,避免 # KEY=your_* 示例误伤部署
|
||
if grep -Ev '^[[:space:]]*#' "$ENV_SRC" | grep -Eq '=(your_|replace_with_|\\.{3})$'; then
|
||
echo "::error::$ENV_SRC 仍包含占位符值,请先完善环境文件。"
|
||
exit 1
|
||
fi
|
||
if grep -Ev '^[[:space:]]*#' "$ENV_SRC" | grep -Eq '^DATABASE_URL=.*@localhost:' || grep -Ev '^[[:space:]]*#' "$ENV_SRC" | grep -Eq '^REDIS_URL=redis://localhost'; then
|
||
echo "::error::$ENV_SRC 包含 localhost 数据库或 Redis 地址,容器内将无法连接。"
|
||
exit 1
|
||
fi
|
||
if grep -Ev '^[[:space:]]*#' "$ENV_SRC" | grep -Eq '^DATABASE_URL=.*@postgresql:'; 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
|
||
"
|