Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in / Register
Toggle navigation
S
sumeiqiao
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xuchentao
sumeiqiao
Commits
c5cd0ab3
Commit
c5cd0ab3
authored
Jul 23, 2026
by
xuchentao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
ci: add safe GitLab release deployment
parent
d925be74
Pipeline
#405
canceled with stages
Changes
4
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
368 additions
and
8 deletions
+368
-8
.gitlab-ci.yml
.gitlab-ci.yml
+112
-0
CMS_README.md
CMS_README.md
+22
-8
cleanup-releases.sh
scripts/cleanup-releases.sh
+65
-0
deploy-release.sh
scripts/deploy-release.sh
+169
-0
No files found.
.gitlab-ci.yml
0 → 100644
View file @
c5cd0ab3
# 塑美俏官网 · GitLab CI/CD 安全部署
# 流程:准备 release → 安装依赖 → 安全部署 → 验证与清理
# 触发:push 到 main 分支
#
# ⚠️ GitLab 版本:11.7(较旧)
# - 不使用 workflow:rules
# - 不使用 rules
# - 不使用 needs/DAG
# - 不使用 resource_group
# - 不使用 cache:policy
# 仅使用 only、stages、script、when 等基础语法。
variables
:
DEPLOY_ROOT
:
"
/root/sumeiqiao"
RELEASES_DIR
:
"
/root/sumeiqiao/releases"
SHARED_DIR
:
"
/root/sumeiqiao/shared"
SHARED_ENV
:
"
/root/sumeiqiao/shared/.env"
APP_NAME
:
"
sumeiqiao"
PORT
:
"
8789"
SITE_URL
:
"
http://127.0.0.1:8789/"
PM2_BIN
:
"
/usr/bin/pm2"
PM2_HOME_DIR
:
"
/root/.pm2"
PM2_USE_SUDO
:
"
1"
stages
:
-
prepare
-
install
-
deploy
-
verify
# 所有 job 使用同一目录。CI_PIPELINE_ID 保证不同 pipeline 不会写同一个 release。
# 不使用 CI_JOB_ID,因为同一 pipeline 的不同 job 必须共享 release。
# ── 1. 准备独立 release ───────────────────────────────
prepare
:
stage
:
prepare
only
:
-
main
script
:
-
export RELEASE_DIR="$RELEASES_DIR/${CI_COMMIT_SHA}-${CI_PIPELINE_ID}"
-
echo "[prepare] 提交:$CI_COMMIT_SHA"
-
echo "[prepare] Release:$RELEASE_DIR"
-
git config --global --add safe.directory "$CI_PROJECT_DIR"
-
test -n "$CI_COMMIT_SHA" || (echo "CI_COMMIT_SHA 缺失" && exit 1)
-
test -n "$CI_PIPELINE_ID" || (echo "CI_PIPELINE_ID 缺失" && exit 1)
-
test -f "$SHARED_ENV" || (echo "缺少生产配置:$SHARED_ENV" && exit 1)
-
mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$RELEASE_DIR"
# GitLab Runner 已检出本次提交;直接导出受 Git 管理的文件,不修改正在运行的 current。
-
git archive "$CI_COMMIT_SHA" | tar -x -C "$RELEASE_DIR"
-
printf '%s\n' "$CI_COMMIT_SHA" > "$RELEASE_DIR/.commit-sha"
-
printf '%s\n' "$CI_PIPELINE_ID" > "$RELEASE_DIR/.pipeline-id"
-
test -L "$RELEASE_DIR/.env" || ln -s "$SHARED_ENV" "$RELEASE_DIR/.env"
-
echo "[prepare] Release 准备完成"
-
ls -la "$RELEASE_DIR" | sed -n '1,30p'
# ── 2. 安装依赖 ──────────────────────────────────────
# release 尚未启用,安装失败不会影响线上服务,也不需要占用 CMS 构建锁。
install
:
stage
:
install
only
:
-
main
script
:
-
export RELEASE_DIR="$RELEASES_DIR/${CI_COMMIT_SHA}-${CI_PIPELINE_ID}"
-
echo "[install] 开始安装依赖:$RELEASE_DIR"
-
cd "$RELEASE_DIR"
-
npm ci
-
echo "[install] 依赖安装完成"
-
du -sh node_modules
# ── 3. 安全部署 ──────────────────────────────────────
# 共享锁内重新读取最新文章并构建,然后原子切换 current、仅重启本项目 PM2、健康检查。
# 常规失败会在锁内自动恢复上一成功版本。
deploy
:
stage
:
deploy
only
:
-
main
script
:
-
export RELEASE_DIR="$RELEASES_DIR/${CI_COMMIT_SHA}-${CI_PIPELINE_ID}"
-
echo "[deploy] 等待 CMS 共享构建锁"
-
echo "[deploy] 目标 Release:$RELEASE_DIR"
-
test -x "$PM2_BIN" || (echo "PM2 不存在或不可执行:$PM2_BIN" && exit 1)
-
cd "$RELEASE_DIR"
-
node --env-file="$SHARED_ENV" scripts/run-with-site-lock.mjs -- bash scripts/deploy-release.sh "$DEPLOY_ROOT" "$RELEASE_DIR" "$APP_NAME" "$PM2_BIN" "$PM2_HOME_DIR" "$PM2_USE_SUDO" "$SITE_URL" "$CI_PIPELINE_ID"
-
echo "[deploy] 当前版本:$(readlink -f "$DEPLOY_ROOT/current")"
# ── 4. 验证并只保留当前 + 上一个成功版本 ─────────────
# when: always 用于输出最终状态;生产健康检查和自动回退已经在 deploy 的共享锁内完成。
verify
:
stage
:
verify
only
:
-
main
when
:
always
script
:
-
export RELEASE_DIR="$RELEASES_DIR/${CI_COMMIT_SHA}-${CI_PIPELINE_ID}"
-
echo "[verify] 本次 Release:$RELEASE_DIR"
-
echo "[verify] 当前指向:$(readlink -f "$DEPLOY_ROOT/current" 2>/dev/null || echo 未部署)"
-
|
verify_status=0
current_target=$(readlink -f "$DEPLOY_ROOT/current" 2>/dev/null || true)
if [ "$current_target" = "$RELEASE_DIR" ]; then
if curl --fail --silent --show-error --max-time 5 "$SITE_URL" >/dev/null; then
echo "[verify] 服务运行正常:$SITE_URL"
else
echo "[verify] 服务无响应,输出本项目最近日志"
sudo -n env PM2_HOME="$PM2_HOME_DIR" "$PM2_BIN" logs "$APP_NAME" --lines 30 --nostream || true
verify_status=1
fi
else
echo "[verify] 本次版本未部署成功,线上 current 保持在其他版本"
fi
bash "$CI_PROJECT_DIR/scripts/cleanup-releases.sh" "$DEPLOY_ROOT" "$RELEASE_DIR"
exit "$verify_status"
CMS_README.md
View file @
c5cd0ab3
...
@@ -98,19 +98,33 @@ CMS_DATA_DIR=/srv/sumeiqiao/shared/cms-data
...
@@ -98,19 +98,33 @@ CMS_DATA_DIR=/srv/sumeiqiao/shared/cms-data
CMS_BUILD_LOCK=/srv/sumeiqiao/shared/site-build.lock
CMS_BUILD_LOCK=/srv/sumeiqiao/shared/site-build.lock
```
```
CI/CD 应在新的 release 目录
检出代码,不要在正在运行的目录执行
`git clean`
。安装依赖后,让“构建、切换 current 软链接、重启服务”整个过程持有同一把锁:
CI/CD 应在新的 release 目录
部署代码,不要在正在运行的目录执行
`git clean`
。仓库中的
`.gitlab-ci.yml`
兼容 GitLab 11.7,分为准备、安装、部署、验证四个阶段,每个阶段都会实时输出日志。安装依赖只操作尚未启用的独立 release;最终生产构建、切换 current 软链接、重启 PM2 和健康检查在同一把共享锁内完成。健康检查失败时会自动切回上一版本。
```
bash
```
bash
node
scripts/run-with-site-lock.mjs
--
./deploy-release.sh
node
--env-file
=
/root/sumeiqiao/shared/.env scripts/run-with-site-lock.mjs
--
bash scripts/deploy-release.sh /root/sumeiqiao /root/sumeiqiao/releases/<release> sumeiqiao /usr/bin/pm2 /root/.pm2 1 http://127.0.0.1:8789/
```
```
其中
`deploy-release.sh`
在锁内执行:
### GitLab CI/CD 配置
```
bash
默认假设 GitLab Shell Runner 与生产服务位于同一台服务器,并使用以下路径:
npm ci
npm run build:inside-lock
-
部署根目录:
`/root/sumeiqiao`
# 原子切换 /srv/sumeiqiao/current 软链接
-
持久化配置:
`/root/sumeiqiao/shared/.env`
# 重启 systemd/pm2 服务
-
PM2 应用名:
`sumeiqiao`
-
服务端口:
`8789`
如果服务器实际路径或 PM2 应用名不同,只修改
`.gitlab-ci.yml`
顶部变量即可。脚本只会对
`APP_NAME`
指定的一个 PM2 进程执行
`describe`
、
`delete`
、
`start`
,不会停止、重启或保存其他 PM2 项目。
服务器需提前创建
`shared/.env`
,至少配置
`CMS_DATA_DIR`
、
`CMS_BUILD_LOCK`
、
`CMS_PASSWORD`
、
`CMS_SECRET`
、
`CMS_API_KEY`
和
`CMS_PORT`
。其中建议使用:
```
env
CMS_DATA_DIR=/root/sumeiqiao/shared/cms-data
CMS_BUILD_LOCK=/root/sumeiqiao/shared/site-build.lock
CMS_PORT=8789
```
```
多个 GitLab pipeline 即使同时进入部署阶段,也会由服务器共享锁依次执行;部署持锁时,文章后台不会排队受理发布操作,而会明确提示编辑人员稍后重试。
验证阶段只保留当前版本和上一个成功版本。清理脚本不会删除其他 pipeline 尚未完成的 release;本 pipeline 失败产生的目录会在自己的验证阶段清理。若 Runner 被强制终止导致验证阶段未执行,可能留下一个未完成目录,但不会影响线上版本,可在确认没有对应 pipeline 运行后手动删除。
普通本地构建或不包含 release 切换的 CI 可以直接运行
`npm run build`
,它会自行取得同一把锁。这样两条流水线不会同时读取或覆盖文章数据,也不会同时替换静态网站。
普通本地构建或不包含 release 切换的 CI 可以直接运行
`npm run build`
,它会自行取得同一把锁。这样两条流水线不会同时读取或覆盖文章数据,也不会同时替换静态网站。
scripts/cleanup-releases.sh
0 → 100644
View file @
c5cd0ab3
#!/usr/bin/env bash
set
-Eeuo
pipefail
deploy_root
=
${
1
:?缺少部署根目录
}
pipeline_release
=
${
2
:-}
case
"
$deploy_root
"
in
/
*
)
;;
*
)
echo
"部署根目录必须是绝对路径"
>
&2
;
exit
2
;;
esac
deploy_root
=
$(
readlink
-f
"
$deploy_root
"
)
releases_dir
=
"
$deploy_root
/releases"
current_link
=
"
$deploy_root
/current"
previous_marker
=
"
$deploy_root
/shared/previous-release"
if
[
-n
"
$pipeline_release
"
]
;
then
pipeline_release
=
$(
readlink
-f
"
$pipeline_release
"
)
case
"
$pipeline_release
"
in
"
$releases_dir
"
/
*
)
;;
*
)
echo
"本 pipeline 的 release 路径越界"
>
&2
;
exit
2
;;
esac
fi
if
[
!
-d
"
$releases_dir
"
]
;
then
echo
"[cleanup] Releases 目录不存在,无需清理"
exit
0
fi
current_target
=
$(
readlink
-f
"
$current_link
"
2>/dev/null
||
true
)
previous_target
=
""
if
[
-f
"
$previous_marker
"
]
;
then
previous_target
=
$(
sed
-n
'1p'
"
$previous_marker
"
)
if
[
-n
"
$previous_target
"
]
&&
[
-d
"
$previous_target
"
]
;
then
previous_target
=
$(
readlink
-f
"
$previous_target
"
)
fi
fi
echo
"[cleanup] 保留当前版本:
${
current_target
:-
无
}
"
echo
"[cleanup] 保留上一成功版本:
${
previous_target
:-
无
}
"
for
candidate
in
"
$releases_dir
"
/
*
;
do
[
-e
"
$candidate
"
]
||
continue
if
[
-L
"
$candidate
"
]
||
[
!
-d
"
$candidate
"
]
;
then
echo
"[cleanup] 跳过非普通目录:
$candidate
"
continue
fi
resolved
=
$(
readlink
-f
"
$candidate
"
)
case
"
$resolved
"
in
"
$releases_dir
"
/
*
)
;;
*
)
echo
"[cleanup] 跳过越界路径:
$candidate
"
;
continue
;;
esac
if
[
"
$resolved
"
=
"
$current_target
"
]
||
{
[
-n
"
$previous_target
"
]
&&
[
"
$resolved
"
=
"
$previous_target
"
]
;
}
;
then
echo
"[cleanup] 保留:
$resolved
"
continue
fi
if
[
"
$resolved
"
=
"
$pipeline_release
"
]
||
[
-f
"
$resolved
/.deploy-success"
]
;
then
echo
"[cleanup] 删除旧 Release:
$resolved
"
rm
-rf
--
"
$resolved
"
else
echo
"[cleanup] 跳过未完成 Release(可能属于其他 pipeline):
$resolved
"
fi
done
echo
"[cleanup] 清理完成,当前 Releases:"
find
"
$releases_dir
"
-mindepth
1
-maxdepth
1
-type
d
-print
|
sort
scripts/deploy-release.sh
0 → 100644
View file @
c5cd0ab3
#!/usr/bin/env bash
set
-Eeuo
pipefail
deploy_root
=
${
1
:?缺少部署根目录
}
release_dir
=
${
2
:?缺少
release 目录
}
app_name
=
${
3
:?缺少
PM2 应用名
}
pm2_bin
=
${
4
:?缺少
PM2 可执行文件路径
}
pm2_home
=
${
5
:?缺少
PM2_HOME
}
pm2_use_sudo
=
${
6
:-
1
}
healthcheck_url
=
${
7
:-
http
://127.0.0.1:
${
CMS_PORT
:-
8789
}
/
}
pipeline_id
=
${
8
:?缺少
CI pipeline ID
}
case
"
$deploy_root
"
in
/
*
)
;;
*
)
echo
"部署根目录必须是绝对路径"
>
&2
;
exit
2
;;
esac
case
"
$release_dir
"
in
"
$deploy_root
"
/releases/
*
)
;;
*
)
echo
"release 目录必须位于
$deploy_root
/releases/ 下"
>
&2
;
exit
2
;;
esac
case
"
$app_name
"
in
*
[!
A-Za-z0-9_.-]
*
|
''
)
echo
"PM2 应用名包含不安全字符"
>
&2
;
exit
2
;;
esac
case
"
$pm2_bin
"
in
/
*
)
;;
*
)
echo
"PM2 可执行文件必须是绝对路径"
>
&2
;
exit
2
;;
esac
case
"
$pm2_home
"
in
/
*
)
;;
*
)
echo
"PM2_HOME 必须是绝对路径"
>
&2
;
exit
2
;;
esac
case
"
$pm2_use_sudo
"
in
0|1
)
;;
*
)
echo
"PM2_USE_SUDO 只能是 0 或 1"
>
&2
;
exit
2
;;
esac
case
"
$pipeline_id
"
in
*
[!
0-9]
*
|
''
)
echo
"CI pipeline ID 必须是数字"
>
&2
;
exit
2
;;
esac
shared_dir
=
"
$deploy_root
/shared"
shared_env
=
"
$shared_dir
/.env"
current_link
=
"
$deploy_root
/current"
if
[
"
${
SUMEIQIAO_SITE_LOCK_HELD
:-}
"
!=
"1"
]
;
then
echo
"部署必须通过 run-with-site-lock.mjs 执行"
>
&2
exit
2
fi
if
[
!
-d
"
$release_dir
"
]
||
[
!
-f
"
$release_dir
/package-lock.json"
]
;
then
echo
"release 目录不完整:
$release_dir
"
>
&2
exit
2
fi
if
[
!
-f
"
$shared_env
"
]
;
then
echo
"缺少生产环境配置:
$shared_env
"
>
&2
exit
2
fi
case
"
${
CMS_DATA_DIR
:-}
"
in
"
$shared_dir
"
/
*
)
;;
*
)
echo
"CMS_DATA_DIR 必须位于
$shared_dir
/ 下"
>
&2
;
exit
2
;;
esac
case
"
${
CMS_BUILD_LOCK
:-}
"
in
"
$shared_dir
"
/
*
)
;;
*
)
echo
"CMS_BUILD_LOCK 必须位于
$shared_dir
/ 下"
>
&2
;
exit
2
;;
esac
if
[
-e
"
$current_link
"
]
&&
[
!
-L
"
$current_link
"
]
;
then
echo
"
$current_link
必须是软链接,拒绝覆盖真实目录"
>
&2
exit
2
fi
command
-v
curl
>
/dev/null
test
-x
"
$pm2_bin
"
if
[
!
-f
"
$release_dir
/.pipeline-id"
]
||
[
"
$(
cat
"
$release_dir
/.pipeline-id"
)
"
!=
"
$pipeline_id
"
]
;
then
echo
"release 的 pipeline 标识不匹配"
>
&2
exit
2
fi
pm2_command
()
{
if
[
"
$pm2_use_sudo
"
=
"1"
]
;
then
sudo
-n
env
PM2_HOME
=
"
$pm2_home
"
"
$pm2_bin
"
"
$@
"
else
env
PM2_HOME
=
"
$pm2_home
"
"
$pm2_bin
"
"
$@
"
fi
}
# 只删除并重建 APP_NAME 对应的一个进程;不执行 pm2 kill、delete all、restart all 或 pm2 save。
start_current_app
()
{
if
pm2_command describe
"
$app_name
"
>
/dev/null 2>&1
;
then
pm2_command delete
"
$app_name
"
fi
pm2_command start
"
$current_link
/server.mjs"
\
--name
"
$app_name
"
\
--cwd
"
$current_link
"
\
--node-args
=
"--env-file=
$shared_env
"
}
previous_target
=
""
if
[
-L
"
$current_link
"
]
;
then
previous_target
=
$(
readlink
-f
"
$current_link
"
)
fi
# GitLab 11.7 没有 resource_group。若较新的 pipeline 已上线,拒绝旧 pipeline 覆盖它。
if
[
-n
"
$previous_target
"
]
&&
[
-f
"
$previous_target
/.pipeline-id"
]
;
then
current_pipeline_id
=
$(
cat
"
$previous_target
/.pipeline-id"
)
case
"
$current_pipeline_id
"
in
*
[!
0-9]
*
|
''
)
echo
"当前版本的 pipeline 标识不合法"
>
&2
;
exit
2
;;
esac
if
[
"
$current_pipeline_id
"
-gt
"
$pipeline_id
"
]
;
then
echo
"较新的 pipeline
$current_pipeline_id
已部署,拒绝旧 pipeline
$pipeline_id
覆盖"
>
&2
exit
3
fi
if
[
"
$current_pipeline_id
"
-eq
"
$pipeline_id
"
]
&&
[
"
$previous_target
"
=
"
$release_dir
"
]
;
then
echo
"该 pipeline 已经部署,无需重复切换"
exit
0
fi
fi
switched
=
0
rollback
()
{
exit_code
=
$?
trap
- ERR
if
[
"
$switched
"
=
"1"
]
;
then
echo
"新版本部署失败,正在恢复上一版本……"
>
&2
rollback_link
=
"
$deploy_root
/.current-rollback-
$$
"
if
[
-n
"
$previous_target
"
]
&&
[
-d
"
$previous_target
"
]
;
then
ln
-s
"
$previous_target
"
"
$rollback_link
"
mv
-Tf
"
$rollback_link
"
"
$current_link
"
start_current_app
||
true
else
if
pm2_command describe
"
$app_name
"
>
/dev/null 2>&1
;
then
pm2_command delete
"
$app_name
"
||
true
fi
echo
"没有可恢复的上一版本,已停止本项目进程"
>
&2
fi
fi
exit
"
$exit_code
"
}
trap
rollback ERR
cd
"
$release_dir
"
echo
"[deploy] 使用共享用户内容执行最终构建"
npm run build:inside-lock
next_link
=
"
$deploy_root
/.current-next-
$$
"
ln
-s
"
$release_dir
"
"
$next_link
"
mv
-Tf
"
$next_link
"
"
$current_link
"
switched
=
1
start_current_app
healthy
=
0
for
attempt
in
$(
seq
1 30
)
;
do
if
curl
--fail
--silent
--show-error
--max-time
3
"
$healthcheck_url
"
>
/dev/null
;
then
healthy
=
1
break
fi
echo
"等待服务启动... (
$attempt
/30)"
sleep
1
done
if
[
"
$healthy
"
!=
"1"
]
;
then
echo
"健康检查失败:
$healthcheck_url
"
>
&2
pm2_command logs
"
$app_name
"
--lines
30
--nostream
||
true
false
fi
trap
- ERR
previous_marker_tmp
=
"
$shared_dir
/.previous-release-
$$
"
printf
'%s\n'
"
$previous_target
"
>
"
$previous_marker_tmp
"
mv
-f
"
$previous_marker_tmp
"
"
$shared_dir
/previous-release"
printf
'%s\n'
"
$pipeline_id
"
>
"
$release_dir
/.deploy-success"
echo
"部署成功:
$release_dir
"
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment