Files
life-echo/.github/workflows/docker-build-deploy.yml
2026-03-23 10:25:51 +08:00

312 lines
12 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API Dockermain → 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
"