312 lines
12 KiB
YAML
312 lines
12 KiB
YAML
# API Docker:main → Dev 机(Repository secrets: DEV_*),Tag v*.*.* → Prod 机(PROD_*)
|
||
# 在 Repo → Settings → Secrets and variables → Actions 中配置,无需 GitHub Environments。
|
||
# 命名:DEV_SSH_HOST / DEV_SSH_USER / DEV_SSH_PRIVATE_KEY / DEV_SSH_PORT / DEV_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 等无前缀名称,
|
||
# 请把「原机 / 内部测试」对应值复制为 DEV_*,「新生产机」填 PROD_*,并删除旧的无前缀 Secret。
|
||
#
|
||
# 发布策略:
|
||
# - merge / push 到 main:构建并部署到 Dev / 内部测试
|
||
# - 手动创建并推送 tag vMAJOR.MINOR.PATCH:构建并部署到 Production
|
||
#
|
||
# 注意: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=dev" >> "$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 development SSH secret is set
|
||
if: steps.deploy_target.outputs.target != 'prod'
|
||
env:
|
||
DEV_SSH_PRIVATE_KEY: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
|
||
run: |
|
||
if [ -z "$DEV_SSH_PRIVATE_KEY" ]; then
|
||
echo "::error::DEV_SSH_PRIVATE_KEY 未配置或为空,无法部署开发机。请在 Repository secrets 中设置 DEV_SSH_*。"
|
||
exit 1
|
||
fi
|
||
|
||
# 勿用 `prod && PROD_KEY || DEV_KEY`:PROD 为空时会错误回退到 DEV 密钥,导致连生产机报 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 (development)
|
||
if: steps.deploy_target.outputs.target != 'prod'
|
||
uses: webfactory/ssh-agent@v0.9.1
|
||
with:
|
||
ssh-private-key: ${{ secrets.DEV_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.DEV_SSH_HOST }}"
|
||
echo "SSH_USER=${{ secrets.DEV_SSH_USER }}"
|
||
echo "SSH_PORT=${{ secrets.DEV_SSH_PORT || '22' }}"
|
||
echo "COMPOSE_DIR=${{ secrets.DEV_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
|
||
"
|
||
|
||
echo "上传候选 compose、Caddyfile 与环境变量..."
|
||
scp -P "$SSH_PORT" ./api/docker-compose.yml "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/docker-compose.candidate.yml"
|
||
scp -P "$SSH_PORT" ./api/Caddyfile "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/Caddyfile.candidate"
|
||
scp -P "$SSH_PORT" ./api/.env.production "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/.env.production.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 'Caddyfile' ]; then
|
||
cp 'Caddyfile' 'Caddyfile.predeploy'
|
||
fi
|
||
if [ -f '.env.production' ]; then
|
||
cp '.env.production' '.env.production.predeploy'
|
||
fi
|
||
mv 'docker-compose.candidate.yml' '$COMPOSE_FILE'
|
||
mv 'Caddyfile.candidate' 'Caddyfile'
|
||
mv '.env.production.candidate' '.env.production'
|
||
docker-compose -f '$COMPOSE_FILE' up -d --remove-orphans
|
||
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-compose logs --tail=80 api || true
|
||
exit 1
|
||
fi
|
||
|
||
CADDY_CID=\$(docker-compose ps -q caddy || true)
|
||
if [ -n \"\$CADDY_CID\" ]; then
|
||
CADDY_STATE=\$(docker inspect -f '{{.State.Status}}' \"\$CADDY_CID\")
|
||
echo \"caddy state: \$CADDY_STATE\"
|
||
if [ \"\$CADDY_STATE\" != 'running' ]; then
|
||
echo 'caddy 容器未处于 running 状态'
|
||
docker-compose logs --tail=80 caddy || true
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
docker-compose logs --tail=50 api
|
||
"
|