Repository navigation
183 lines (160 loc) · 7.21 KB
/
Copy pathdeploy.yml
File metadata and controls
183 lines (160 loc) · 7.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
name: Deploy Production
# 触发方式:
# 1. CI(ci.yml)在 main 上成功完成后自动触发(workflow_run)
# 2. 手动 workflow_dispatch,可指定任意历史镜像 tag 做回滚
#
# 服务器只负责:git 同步配置 → 拉 GHCR 镜像 → 迁移 → 重启应用容器 → 冒烟。
# 镜像由 CI 构建并打上 commit sha 标签,因此回滚 = 重新部署旧 tag。
#
# 必须配置的 Secrets:
# DEPLOY_HOST / DEPLOY_PORT / DEPLOY_USER / DEPLOY_SSH_KEY / DEPLOY_PATH
# 可选 Secrets(不配置则依赖服务器上已执行的 docker login ghcr.io):
# GHCR_USER / GHCR_TOKEN (PAT,至少 read:packages)
#
# 服务器上 .env 需要包含 POSTGRES_* / RABBITMQ_* / JWT_SECRET / SEED_ADMIN_*;
# IMAGE_PREFIX / IMAGE_TAG 由本工作流注入,无需写进 .env。
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
image_tag:
description: "要部署的镜像 tag(commit sha 或 latest,留空即 latest)"
required: false
default: ""
concurrency:
group: deploy-production
# 部署绝不能被打断:新任务排队等待,而不是取消正在进行的部署
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
# 只有 CI 成功、且是 main 分支才自动部署;手动触发不受限制
if: >
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main')
runs-on: ubuntu-latest
timeout-minutes: 25
environment: production
steps:
- name: 解析部署版本
id: meta
env:
EVENT: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.image_tag }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
OWNER: ${{ github.repository_owner }}
run: |
if [ "$EVENT" = "workflow_dispatch" ]; then
TAG="${INPUT_TAG:-latest}"
else
TAG="$HEAD_SHA"
fi
PREFIX="ghcr.io/$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')/moon"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "prefix=$PREFIX" >> "$GITHUB_OUTPUT"
echo "将部署:$PREFIX-{backend,frontend}:$TAG"
- name: 部署到服务器
uses: appleboy/ssh-action@v1
env:
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
IMAGE_PREFIX: ${{ steps.meta.outputs.prefix }}
IMAGE_TAG: ${{ steps.meta.outputs.tag }}
GHCR_USER: ${{ secrets.GHCR_USER }}
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
with:
host: ${{ secrets.DEPLOY_HOST }}
port: ${{ secrets.DEPLOY_PORT || 22 }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: DEPLOY_PATH,IMAGE_PREFIX,IMAGE_TAG,GHCR_USER,GHCR_TOKEN
command_timeout: 20m
script: |
set -eu
cd "$DEPLOY_PATH"
COMPOSE="docker compose --env-file .env -f deploy/docker-compose.prod.yml -p blog"
if [ ! -f .env ]; then
echo "::error::缺少 .env,请先 cp deploy/.env.production.example .env 并填写"
exit 1
fi
# 可选:用 CI Secrets 登录 GHCR(不配置则复用服务器已有的 docker login)
if [ -n "${GHCR_TOKEN:-}" ] && [ -n "${GHCR_USER:-}" ]; then
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin >/dev/null
fi
echo ">>> 同步配置代码(只用于 compose / nginx 配置)"
cp -p .env ".env.bak.$(date +%Y%m%d%H%M%S)"
git fetch --prune --tags
# 尽量检出与镜像完全一致的 commit;手动 latest 则用 main
if [ "$IMAGE_TAG" != "latest" ] && git cat-file -e "${IMAGE_TAG}^{commit}" 2>/dev/null; then
git checkout -f "$IMAGE_TAG"
else
git checkout -f origin/main
fi
export IMAGE_PREFIX IMAGE_TAG
# 读取 .env(去掉可能的 CRLF)
GETENV() { sed -n "s/^$1=//p" .env | tail -1 | tr -d '\r'; }
PGUSER=$(GETENV POSTGRES_USER); PGUSER=${PGUSER:-blog}
PGDB=$(GETENV POSTGRES_DB); PGDB=${PGDB:-blog}
echo ">>> 确保基础设施在运行(不重建、不中断)"
timeout 400 $COMPOSE up -d --wait postgres redis rabbitmq elasticsearch
# 迁移前备份数据库;备份失败则终止部署
if docker ps --format '{{.Names}}' | grep -qx blog-postgres; then
mkdir -p backups
TS=$(date +%Y%m%d%H%M%S)
echo ">>> 备份数据库到 backups/db-$TS.sql.gz"
if ! docker exec blog-postgres pg_dump -U "$PGUSER" "$PGDB" > "backups/db-$TS.sql"; then
rm -f "backups/db-$TS.sql"
echo "::error::数据库备份失败,终止部署(未做任何变更)"
exit 1
fi
gzip -f "backups/db-$TS.sql"
# 只保留最近 5 份
ls -1t backups/db-*.sql.gz | tail -n +6 | xargs -r rm -f || true
fi
PREV_TAG=""
if [ -f .deployed_tag ]; then PREV_TAG=$(tr -d '\r' < .deployed_tag); fi
deploy() {
set +e
echo ">>> 拉取镜像 $IMAGE_PREFIX-{backend,frontend}:$IMAGE_TAG"
$COMPOSE pull backend frontend || return 1
echo ">>> 数据库迁移"
$COMPOSE run --rm --no-deps backend /app/migrate || return 1
echo ">>> 同步管理员账号(已存在则不改密码)"
$COMPOSE run --rm --no-deps backend /app/seed || return 1
echo ">>> 启动应用容器(等待 healthcheck)"
timeout 180 $COMPOSE up -d --no-deps --force-recreate --wait backend frontend nginx || return 1
echo ">>> 冒烟测试"
for i in $(seq 1 15); do
if curl -fsS http://127.0.0.1/healthz >/dev/null 2>&1 && \
curl -fsS -o /dev/null http://127.0.0.1/ 2>/dev/null; then
return 0
fi
sleep 2
done
return 1
}
if deploy; then
echo "$IMAGE_TAG" > .deployed_tag
echo "✅ 部署成功:$IMAGE_TAG"
else
echo "::error::部署失败"
$COMPOSE logs --tail=120 backend frontend nginx || true
if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$IMAGE_TAG" ]; then
echo ">>> 回滚到上一个版本:$PREV_TAG"
export IMAGE_TAG="$PREV_TAG"
$COMPOSE pull backend frontend || true
timeout 180 $COMPOSE up -d --no-deps --force-recreate --wait backend frontend nginx || true
echo "::warning::已尝试回滚到 $PREV_TAG;数据库备份在 backups/,必要时手动恢复"
else
echo "::warning::没有可用的回滚版本"
fi
exit 1
fi
echo ">>> 清理悬空镜像"
docker image prune -f >/dev/null 2>&1 || true
echo ">>> 当前容器状态"
$COMPOSE ps