feat(deploy): add atomic npm deploy and focus cache purge

This commit is contained in:
2026-10-21 04:58:10 +08:00
parent 206b335947
commit bf3012f8e9
2 changed files with 992 additions and 0 deletions

991
deploy.sh Executable file
View File

@@ -0,0 +1,991 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
cd "$ROOT_DIR"
REPO_NAME="focus"
REMOTE_HOST="ubuntu@43.163.240.46"
REMOTE_DIR="/home/ubuntu/repo/$REPO_NAME"
CF_ENV_FILE="$HOME/.pi/agent/skills/cloudflare/.env"
# 1. 本地单飞锁:使用稳定且权限安全的单一锁文件,避免多任务并发与扫描竞态
LOCAL_LOCK_FILE=$(git rev-parse --path-format=absolute --git-path focus-deploy.lock 2>/dev/null || true)
if [[ -z "$LOCAL_LOCK_FILE" ]]; then
LOCAL_LOCK_PATH=$(git rev-parse --git-path focus-deploy.lock 2>/dev/null || true)
if [[ -n "$LOCAL_LOCK_PATH" ]]; then
if [[ "$LOCAL_LOCK_PATH" = /* ]]; then
LOCAL_LOCK_FILE="$LOCAL_LOCK_PATH"
else
LOCAL_LOCK_FILE="$ROOT_DIR/$LOCAL_LOCK_PATH"
fi
else
LOCAL_LOCK_FILE="$ROOT_DIR/.git/focus-deploy.lock"
fi
fi
mkdir -p "$(dirname "$LOCAL_LOCK_FILE")"
exec 9>"$LOCAL_LOCK_FILE"
if ! flock -n 9; then
echo "已有本地部署任务在运行,请稍候" >&2
exit 1
fi
# 2. 创建私有临时目录并注册清理 trap(避免固定 /tmp 路径的 symlink 与权限冲突)
TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/focus-deploy.XXXXXX")
chmod 700 "$TMP_DIR"
SSH_SOCKET="$TMP_DIR/ssh.sock"
COORD_PID=""
COORD_IN_FD_OPEN=""
COORD_OUT_FD_OPEN=""
REMOTE_SWITCH_FILE=""
REMOTE_STAGING_DIR=""
SWITCH_CALLED=""
SWITCH_COMPLETED=""
CF_PURGE_SUCCESS=0
cleanup() {
local exit_code=$?
local i
trap - EXIT INT TERM
exec 9>&- 2>/dev/null || true
if [[ -n "${COORD_IN_FD_OPEN:-}" ]]; then
exec 3>&- 2>/dev/null || true
COORD_IN_FD_OPEN=""
fi
if [[ -n "${COORD_OUT_FD_OPEN:-}" ]]; then
exec 4<&- 2>/dev/null || true
COORD_OUT_FD_OPEN=""
fi
if [[ -n "${COORD_PID:-}" ]]; then
kill "$COORD_PID" 2>/dev/null || true
for (( i = 0; i < 20; i++ )); do
kill -0 "$COORD_PID" 2>/dev/null || break
sleep 0.1
done
kill -9 "$COORD_PID" 2>/dev/null || true
wait "$COORD_PID" 2>/dev/null || true
COORD_PID=""
fi
# 部署未成功时保留远端 staging 目录与 helper 脚本供排查;全部验证成功后若仍有残留 helper 脚本则清理
if [[ -z "${SWITCH_COMPLETED:-}" ]]; then
if [[ -n "${REMOTE_STAGING_DIR:-}" || -n "${REMOTE_SWITCH_FILE:-}" ]]; then
echo "提示:部署未成功,远端 staging (${REMOTE_STAGING_DIR:-none}) 与切换脚本 (${REMOTE_SWITCH_FILE:-none}) 已保留" >&2
fi
elif [[ -n "${REMOTE_SWITCH_FILE:-}" && -e "${SSH_SOCKET:-}" ]]; then
timeout 5s ssh -S "$SSH_SOCKET" -o "ConnectTimeout=5" "$REMOTE_HOST" \
"rm -f -- '$REMOTE_DIR/$REMOTE_SWITCH_FILE'" 2>/dev/null || true
fi
if [[ -n "${SSH_SOCKET:-}" && -e "$SSH_SOCKET" ]]; then
timeout 10s ssh -o ControlPath="$SSH_SOCKET" -O exit "$REMOTE_HOST" 2>/dev/null || true
fi
if [[ -n "${TMP_DIR:-}" && -d "$TMP_DIR" ]]; then
rm -rf -- "$TMP_DIR"
fi
exit "$exit_code"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
read_dotenv_value() {
local wanted_key="$1"
local line key value result=""
[[ -r "$CF_ENV_FILE" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" == export\ * ]] && line=${line#export }
[[ "$line" == *=* ]] || continue
key=${line%%=*}
key="${key#"${key%%[![:space:]]*}"}"
key="${key%"${key##*[![:space:]]}"}"
[[ "$key" == "$wanted_key" ]] || continue
value=${line#*=}
value=${value%$'\r'}
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
if [[ "$value" == \"*\" && ${#value} -ge 2 ]]; then
value=${value:1:${#value}-2}
elif [[ "$value" == \'*\' && ${#value} -ge 2 ]]; then
value=${value:1:${#value}-2}
fi
result=$value
done < "$CF_ENV_FILE"
printf '%s' "$result"
}
API_TOKEN=${CF_API_TOKEN:-${CLOUDFLARE_API_TOKEN:-}}
ZONE_ID=${CF_ZONE_ID:-${CLOUDFLARE_ZONE_ID:-}}
if [[ -z "$API_TOKEN" ]]; then
API_TOKEN=$(read_dotenv_value CLOUDFLARE_API_TOKEN)
fi
if [[ -z "$ZONE_ID" ]]; then
ZONE_ID=$(read_dotenv_value CLOUDFLARE_ZONE_ID)
fi
if [[ -z "$API_TOKEN" || -z "$ZONE_ID" ]]; then
echo "缺少 Cloudflare API token 或 zone ID;请设置 CF_* / CLOUDFLARE_* 环境变量或配置 $CF_ENV_FILE" >&2
exit 1
fi
echo "==> 本地构建前端产物"
npm run build
for file in index.html favicon.svg favicon.ico favicon-16x16.png favicon-32x32.png apple-touch-icon.png manifest.json pwa-192.png pwa-512.png; do
if [[ ! -s "$ROOT_DIR/dist/$file" ]]; then
echo "构建产物缺失或为空:dist/$file" >&2
exit 1
fi
done
# 确保 dist 目录不包含任何构建元数据或部署脚本(避免敏感运维文件对外发布)
rm -f "$ROOT_DIR/dist/.build_info" "$ROOT_DIR/dist/.deploy_switch.sh"
echo "==> 计算各静态资源独立版本并注入 cache-bust 查询参数"
VERSIONS_FILE="$TMP_DIR/icon_versions.env"
python3 -I - "$ROOT_DIR/dist" "$VERSIONS_FILE" <<'PY'
import hashlib
import json
import os
import re
import sys
dist_dir = sys.argv[1]
versions_file = sys.argv[2]
def get_hash(filename):
filepath = os.path.join(dist_dir, filename)
if not os.path.isfile(filepath):
raise SystemExit(f"缺少产物文件: {filepath}")
with open(filepath, "rb") as f:
data = f.read()
if not data:
raise SystemExit(f"产物文件为空: {filepath}")
return hashlib.sha256(data).hexdigest()[:12]
# 1. 计算所有独立静态图标资源的哈希
initial_icon_map = {
"ICON_VERSION_SVG": "favicon.svg",
"ICON_VERSION_ICO": "favicon.ico",
"ICON_VERSION_FAV16": "favicon-16x16.png",
"ICON_VERSION_FAV32": "favicon-32x32.png",
"ICON_VERSION_APPLE": "apple-touch-icon.png",
"ICON_VERSION_PWA192": "pwa-192.png",
"ICON_VERSION_PWA512": "pwa-512.png",
}
versions = {}
for var_name, fname in initial_icon_map.items():
h = get_hash(fname)
if not re.fullmatch(r"[0-9a-f]{12}", h):
raise SystemExit(f"计算哈希格式异常: {fname} -> {h}")
versions[var_name] = h
# 2. 改写 dist/manifest.json:在 icons.src 中分别加入对应 PWA hash 查询参数
manifest_path = os.path.join(dist_dir, "manifest.json")
with open(manifest_path, "r", encoding="utf-8") as f:
manifest_data = json.load(f)
icons = manifest_data.get("icons", [])
if not isinstance(icons, list):
raise SystemExit("dist/manifest.json 中的 icons 字段非列表格式")
expected_pwa_map = {
"/pwa-192.png": versions["ICON_VERSION_PWA192"],
"/pwa-512.png": versions["ICON_VERSION_PWA512"],
}
pwa_match_counts = {k: 0 for k in expected_pwa_map}
for icon in icons:
if not isinstance(icon, dict):
continue
src = icon.get("src", "")
norm_src = src if src.startswith("/") else f"/{src}"
if norm_src in expected_pwa_map:
if "?" in src:
raise SystemExit(f"dist/manifest.json 中图标 {src} 已包含查询参数")
pwa_version = expected_pwa_map[norm_src]
icon["src"] = f"{src}?v={pwa_version}"
pwa_match_counts[norm_src] += 1
for norm_src, count in pwa_match_counts.items():
if count != 1:
raise SystemExit(
f"dist/manifest.json 预期图标 {norm_src} 改写匹配次数异常(预期 1 次,实际 {count} 次)"
)
manifest_text = json.dumps(manifest_data, indent=2, ensure_ascii=False) + "\n"
for norm_src, pwa_version in expected_pwa_map.items():
bare_src = norm_src.lstrip("/")
verify_pwa = re.compile(rf'["\']/?{re.escape(bare_src)}\?v={re.escape(pwa_version)}["\']')
if len(verify_pwa.findall(manifest_text)) != 1:
raise SystemExit(f"dist/manifest.json 图标 {norm_src} 版本化校验失败")
with open(manifest_path, "w", encoding="utf-8") as f:
f.write(manifest_text)
# 3. 写回最终 manifest 后再计算其 hash,供 index 指向,避免其 hash stale
manifest_hash = get_hash("manifest.json")
if not re.fullmatch(r"[0-9a-f]{12}", manifest_hash):
raise SystemExit(f"计算 manifest 哈希格式异常: {manifest_hash}")
versions["ICON_VERSION_MANIFEST"] = manifest_hash
# 4. 改写 dist/index.html:每个 pathname 使用该资源自己的 hash
html_path = os.path.join(dist_dir, "index.html")
with open(html_path, "r", encoding="utf-8") as f:
html = f.read()
html_targets = [
("/favicon.svg", versions["ICON_VERSION_SVG"]),
("/favicon.ico", versions["ICON_VERSION_ICO"]),
("/favicon-16x16.png", versions["ICON_VERSION_FAV16"]),
("/favicon-32x32.png", versions["ICON_VERSION_FAV32"]),
("/apple-touch-icon.png", versions["ICON_VERSION_APPLE"]),
("/manifest.json", versions["ICON_VERSION_MANIFEST"]),
]
for target, res_version in html_targets:
bare_target = target.lstrip("/")
# 验证目标资源尚未包含 query 参数,避免产生重复 query
existing_query_pattern = re.compile(
rf'(?:href|src)=["\']/?{re.escape(bare_target)}\?[^"\']*["\']'
)
if existing_query_pattern.search(html):
raise SystemExit(f"dist/index.html 中目标资源 {target} 已存在查询参数")
pattern = re.compile(
rf'((?:href|src)=["\']/?{re.escape(bare_target)})(["\'])'
)
matches = pattern.findall(html)
if len(matches) != 1:
raise SystemExit(
f"dist/index.html 中目标资源 {target} 匹配次数异常(预期 1 次,实际匹配 {len(matches)} 次)"
)
html = pattern.sub(rf'\g<1>?v={res_version}\g<2>', html)
for target, res_version in html_targets:
bare_target = target.lstrip("/")
verify_pattern = re.compile(
rf'(?:href|src)=["\']/?{re.escape(bare_target)}\?v={re.escape(res_version)}["\']'
)
if len(verify_pattern.findall(html)) != 1:
raise SystemExit(f"dist/index.html 中目标资源 {target} 版本化校验失败")
dup_query_pattern = re.compile(
rf'(?:href|src)=["\']/?{re.escape(bare_target)}\?v={re.escape(res_version)}[?&][^"\']*["\']'
)
if dup_query_pattern.search(html):
raise SystemExit(f"dist/index.html 中目标资源 {target} 检测到重复 query 参数")
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
# 5. 输出 key=value 版本映射给 shell 安全读取
expected_keys = [
"ICON_VERSION_SVG",
"ICON_VERSION_ICO",
"ICON_VERSION_FAV16",
"ICON_VERSION_FAV32",
"ICON_VERSION_APPLE",
"ICON_VERSION_MANIFEST",
"ICON_VERSION_PWA192",
"ICON_VERSION_PWA512",
]
with open(versions_file, "w", encoding="utf-8") as f:
for k in expected_keys:
v = versions.get(k)
if not v or not re.fullmatch(r"[0-9a-f]{12}", v):
raise SystemExit(f"缺少有效版本键值: {k}={v}")
f.write(f"{k}={v}\n")
print("产物缓存穿透版本注入成功:各资源已分别注入独立内容哈希")
PY
while IFS='=' read -r key val || [[ -n "$key" ]]; do
[[ -z "$key" ]] && continue
case "$key" in
ICON_VERSION_SVG|ICON_VERSION_ICO|ICON_VERSION_FAV16|ICON_VERSION_FAV32|ICON_VERSION_APPLE|ICON_VERSION_MANIFEST|ICON_VERSION_PWA192|ICON_VERSION_PWA512)
if [[ "$val" =~ ^[0-9a-f]{12}$ ]]; then
printf -v "$key" '%s' "$val"
else
echo "非法版本哈希值: $key=$val" >&2
exit 1
fi
;;
*)
echo "未知版本键名: $key" >&2
exit 1
;;
esac
done < "$VERSIONS_FILE"
for var in ICON_VERSION_SVG ICON_VERSION_ICO ICON_VERSION_FAV16 ICON_VERSION_FAV32 ICON_VERSION_APPLE ICON_VERSION_MANIFEST ICON_VERSION_PWA192 ICON_VERSION_PWA512; do
if [[ -z "${!var:-}" ]]; then
echo "缺少必要版本变量: $var" >&2
exit 1
fi
done
echo "各资源版本哈希:"
echo " favicon.svg: $ICON_VERSION_SVG"
echo " favicon.ico: $ICON_VERSION_ICO"
echo " favicon-16x16.png: $ICON_VERSION_FAV16"
echo " favicon-32x32.png: $ICON_VERSION_FAV32"
echo " apple-touch-icon: $ICON_VERSION_APPLE"
echo " manifest.json: $ICON_VERSION_MANIFEST"
echo " pwa-192.png: $ICON_VERSION_PWA192"
echo " pwa-512.png: $ICON_VERSION_PWA512"
# 生成安全纯数字标识,用于远端 helper 脚本与独立 staging 目录命名
BUILD_TIME=$(date +%s)
DEPLOY_PID=$$
DEPLOY_RAND=$RANDOM
if [[ ! "$BUILD_TIME" =~ ^[0-9]+$ || ! "$DEPLOY_PID" =~ ^[0-9]+$ || ! "$DEPLOY_RAND" =~ ^[0-9]+$ ]]; then
echo "部署标识生成异常,包含非法字符" >&2
exit 1
fi
REMOTE_SWITCH_FILE=".deploy-switch-${BUILD_TIME}-${DEPLOY_PID}.sh"
REMOTE_SWITCH_PATH="$REMOTE_DIR/$REMOTE_SWITCH_FILE"
STAGING_DIR="dist.next.${BUILD_TIME}.${DEPLOY_PID}.${DEPLOY_RAND}"
REMOTE_STAGING_DIR="$STAGING_DIR"
# 3. 建立本地 SSH ControlMaster 复用连接
echo "==> 建立 SSH 连接并获取远端排他部署锁"
ssh -M -S "$SSH_SOCKET" -o "ControlPersist=300" -o "BatchMode=yes" -o "ConnectTimeout=10" -f -N "$REMOTE_HOST"
ssh -S "$SSH_SOCKET" -o "ConnectTimeout=10" "$REMOTE_HOST" "mkdir -p -- '$REMOTE_DIR'"
# 4. 启动远端持锁部署协调器(跨 staging 上传、目录切换、purge、公网验证全程持锁)
mkfifo "$TMP_DIR/coord_in"
mkfifo "$TMP_DIR/coord_out"
ssh -S "$SSH_SOCKET" "$REMOTE_HOST" \
"bash -s" \
< "$TMP_DIR/coord_in" > "$TMP_DIR/coord_out" 2>&1 &
COORD_PID=$!
exec 3>"$TMP_DIR/coord_in"
COORD_IN_FD_OPEN=1
exec 4<"$TMP_DIR/coord_out"
COORD_OUT_FD_OPEN=1
# 发送初始化握手并在远端获取排他部署锁与注册锁释放 trap
cat <<HANDSHAKE_EOF >&3
set -euo pipefail
REMOTE_DIR='$REMOTE_DIR'
REMOTE_LOCK_FD=200
CURRENT_SWITCH_HELPER=""
eval "exec \${REMOTE_LOCK_FD}>'$REMOTE_DIR/.deploy.lock'"
if ! flock -w 120 -x "\${REMOTE_LOCK_FD}"; then
echo "COORD_LOCK_TIMEOUT"
exit 1
fi
cleanup_coord() {
local code=\$?
trap - EXIT INT TERM HUP
if [[ -n "\${CURRENT_SWITCH_HELPER:-}" ]]; then
if [[ "\$code" -eq 0 ]]; then
rm -f -- "\${CURRENT_SWITCH_HELPER}" 2>/dev/null || true
CURRENT_SWITCH_HELPER=""
else
echo "提示:远端协调器异常或失败退出,已保留切换脚本: \${CURRENT_SWITCH_HELPER}" >&2
fi
fi
flock -u "\${REMOTE_LOCK_FD}" 2>/dev/null || true
eval "exec \${REMOTE_LOCK_FD}>&-" 2>/dev/null || true
exit "\$code"
}
trap cleanup_coord EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
echo "COORD_LOCKED"
HANDSHAKE_EOF
coord_status=""
if ! read -r -t 130 coord_status <&4 || [[ "$coord_status" != "COORD_LOCKED" ]]; then
echo "获取远端部署锁失败,可能有其他部署任务正在运行或网络连接异常" >&2
exit 1
fi
echo "已获取远端排他部署锁(部署串行化由锁获取顺序保证,覆盖 staging 同步、目录切换、Cloudflare purge、公网验证全程)"
# 生成远端原子切换脚本(置于临时目录,禁止写入 dist,杜绝公网意外暴露)
SWITCH_LOCAL_SCRIPT="$TMP_DIR/deploy-switch.sh"
cat > "$SWITCH_LOCAL_SCRIPT" << 'SWITCH_SCRIPT_EOF'
#!/usr/bin/env bash
set -euo pipefail
REMOTE_DIR="/home/ubuntu/repo/focus"
STAGING_DIR="$1"
cd "$REMOTE_DIR"
if [[ -L "$STAGING_DIR" || ! -d "$STAGING_DIR" || ! -s "$STAGING_DIR/index.html" ]]; then
echo "远端 staging 缺少有效 index.html 或为符号链接:$STAGING_DIR" >&2
exit 1
fi
exchange_dirs() {
local dir_a="$1"
local dir_b="$2"
python3 -I - "$dir_a" "$dir_b" <<'PY'
import ctypes
import os
import sys
AT_FDCWD = -100
RENAME_EXCHANGE = 2
libc = ctypes.CDLL(None, use_errno=True)
try:
renameat2 = libc.renameat2
except AttributeError as exc:
raise SystemExit("libc does not provide renameat2") from exc
renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
renameat2.restype = ctypes.c_int
if renameat2(AT_FDCWD, os.fsencode(sys.argv[1]), AT_FDCWD, os.fsencode(sys.argv[2]), RENAME_EXCHANGE) != 0:
error = ctypes.get_errno()
raise OSError(error, os.strerror(error))
PY
}
origin_is_healthy() {
local response
response=$(curl -fsS --connect-timeout 3 --max-time 10 -H 'Host: focus.102434.xyz' http://127.0.0.1/) || return 1
[[ "$response" == *'id="root"'* ]]
}
if [[ -e dist || -L dist ]]; then
if [[ -L dist || ! -d dist || ! -s dist/index.html ]]; then
echo "线上 dist 为符号链接或缺少有效 index.html,拒绝切换" >&2
exit 1
fi
if ! exchange_dirs dist "$STAGING_DIR"; then
echo "原子交换失败,线上 dist 未切换;保留 staging: $STAGING_DIR" >&2
exit 1
fi
if ! origin_is_healthy || [[ -L dist || ! -d dist || ! -s dist/index.html ]]; then
echo "源站健康检查失败,正在回滚" >&2
if exchange_dirs dist "$STAGING_DIR"; then
echo "已恢复上一版 dist;失败版本保留在 $STAGING_DIR" >&2
else
echo "严重告警:回滚交换失败!两份目录均已保留在 dist 与 $STAGING_DIR" >&2
fi
exit 1
fi
# 线上新版本健康检查通过后,归档上一版本(保证 dist.previous 确实是本次 deploy 前的线上版本)
archive_ok=0
if [[ ! -e dist.previous && ! -L dist.previous ]]; then
if mv -- "$STAGING_DIR" dist.previous; then
archive_ok=1
else
echo "归档失败:mv $STAGING_DIR dist.previous 失败" >&2
fi
elif [[ -L dist.previous || ! -d dist.previous || ! -s dist.previous/index.html ]]; then
echo "归档失败:既有 dist.previous 为符号链接或非有效目录(缺少 index.html),禁止作为自动交换与回滚目标" >&2
archive_ok=0
else
# 存在有效 previous,原子 exchange staging 和 dist.previous,再尽力删除 staging 中更老备份
if exchange_dirs "$STAGING_DIR" dist.previous; then
archive_ok=1
# 交换后 dist.previous 为刚才的 old-live,STAGING_DIR 为老 previous
rm -rf -- "$STAGING_DIR" 2>/dev/null || true
else
echo "归档失败:原子交换 STAGING_DIR 与 dist.previous 失败" >&2
fi
fi
if [[ "$archive_ok" -ne 1 ]]; then
echo "归档本次旧版本失败,立即回滚至刚才的线上版本..." >&2
if exchange_dirs dist "$STAGING_DIR"; then
if origin_is_healthy && [[ ! -L dist && -d dist && -s dist/index.html ]]; then
echo "已成功回滚至刚才旧版本,源站健康检查通过;保留既有 dist.previous,中止部署" >&2
exit 1
else
echo "严重告警:已回滚至旧版本,但源站健康检查未通过!两份目录均已保留在 dist 与 $STAGING_DIR" >&2
exit 1
fi
else
echo "严重告警:归档失败且回滚至旧版本失败!两份目录均已保留在 dist 与 $STAGING_DIR" >&2
exit 1
fi
fi
else
# 首次部署
if [[ -L "$STAGING_DIR" || ! -d "$STAGING_DIR" || ! -s "$STAGING_DIR/index.html" ]]; then
echo "初次部署 staging 缺少有效 index.html 或为符号链接:$STAGING_DIR" >&2
exit 1
fi
if ! python3 -I - "$STAGING_DIR" <<'PY'
import os
import sys
os.rename(sys.argv[1], "dist")
PY
then
echo "初次部署目录切换失败;staging 已保留:$STAGING_DIR" >&2
exit 1
fi
if ! origin_is_healthy || [[ -L dist || ! -d dist || ! -s dist/index.html ]]; then
echo "初次部署源站健康检查失败,正在撤回新目录" >&2
if python3 -I - "$STAGING_DIR" <<'PY'
import os
import sys
os.rename("dist", sys.argv[1])
PY
then
echo "新目录已退回 staging:$STAGING_DIR,保留供排查" >&2
else
echo "严重告警:撤回失败;线上目录仍保留,请检查 dist 和 $STAGING_DIR" >&2
fi
exit 1
fi
fi
echo "源站健康检查通过"
SWITCH_SCRIPT_EOF
chmod 700 "$SWITCH_LOCAL_SCRIPT"
echo "==> 同步到远端 staging: $STAGING_DIR"
ssh -S "$SSH_SOCKET" -o "ConnectTimeout=10" "$REMOTE_HOST" "mkdir -m 755 -p -- '$REMOTE_DIR/$STAGING_DIR'"
rsync -az --timeout=60 --delete -e "ssh -S $SSH_SOCKET -o ConnectTimeout=10" -- "$ROOT_DIR/dist/" "$REMOTE_HOST:$REMOTE_DIR/$STAGING_DIR/"
echo "==> 上传远端原子切换脚本: $REMOTE_SWITCH_FILE"
scp -o ControlPath="$SSH_SOCKET" -o "ConnectTimeout=10" -p "$SWITCH_LOCAL_SCRIPT" "$REMOTE_HOST:$REMOTE_SWITCH_PATH"
ssh -S "$SSH_SOCKET" -o "ConnectTimeout=10" "$REMOTE_HOST" "chmod 700 '$REMOTE_SWITCH_PATH'"
echo "==> 远端原子切换线上目录"
SWITCH_CALLED=1
cat <<SW_CMD >&3
CURRENT_SWITCH_HELPER='$REMOTE_SWITCH_PATH'
chmod 700 "\$CURRENT_SWITCH_HELPER"
if bash "\$CURRENT_SWITCH_HELPER" '$STAGING_DIR'; then
echo "SWITCH_OK"
else
echo "SWITCH_FAIL"
exit 1
fi
SW_CMD
switch_status=""
while true; do
if ! IFS= read -r -t 30 line <&4; then
echo "读取远端切换输出超时或连接异常中断(30s 无响应)" >&2
exit 1
fi
if [[ "$line" == "SWITCH_OK" ]]; then
switch_status="OK"
break
elif [[ "$line" == "SWITCH_FAIL" ]]; then
switch_status="FAIL"
break
else
echo " [remote] $line"
fi
done
if [[ "$switch_status" != "OK" ]]; then
echo "线上目录切换失败或健康检查未通过" >&2
exit 1
fi
# SWITCH_COMPLETED 移至全部公网验收通过后再置位,确保任一公网检查失败均能正确触发回滚与失败保护
echo "==> 精确清理 Cloudflare 缓存"
CF_CURL_CONFIG="$TMP_DIR/cf-curl.conf"
install -m 600 /dev/null "$CF_CURL_CONFIG"
cat > "$CF_CURL_CONFIG" <<CF_CFG_EOF
header = "Authorization: Bearer $API_TOKEN"
header = "Content-Type: application/json"
CF_CFG_EOF
PURGE_CURL_FAILED=0
PURGE_HTTP_STATUS=""
if ! PURGE_HTTP_STATUS=$(curl --silent --show-error --retry 2 --retry-all-errors \
--connect-timeout 10 --max-time 60 \
--config "$CF_CURL_CONFIG" \
-X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-o "$TMP_DIR/purge-response.json" -w '%{http_code}' \
--data-binary @- <<'PURGE_JSON'
{"files":[
"https://focus.102434.xyz/",
"https://focus.102434.xyz/index.html",
"https://focus.102434.xyz/manifest.json",
"https://focus.102434.xyz/favicon.svg",
"https://focus.102434.xyz/favicon.ico",
"https://focus.102434.xyz/favicon-16x16.png",
"https://focus.102434.xyz/favicon-32x32.png",
"https://focus.102434.xyz/apple-touch-icon.png",
"https://focus.102434.xyz/pwa-192.png",
"https://focus.102434.xyz/pwa-512.png"
]}
PURGE_JSON
); then
PURGE_CURL_FAILED=1
fi
CF_PURGE_SUCCESS=0
PURGE_PARSE_RESULT="$TMP_DIR/purge-result.txt"
python3 -I - "$TMP_DIR/purge-response.json" "${PURGE_HTTP_STATUS:-000}" "$PURGE_CURL_FAILED" "$PURGE_PARSE_RESULT" <<'PY'
import json
import sys
resp_file = sys.argv[1]
status_raw = sys.argv[2]
curl_failed = sys.argv[3] == "1"
out_file = sys.argv[4]
try:
status = int(status_raw)
except ValueError:
status = 0
if curl_failed:
with open(out_file, "w", encoding="utf-8") as out:
out.write(f"FAIL\tcurl 请求失败 (HTTP {status})")
sys.exit(0)
raw_body = ""
try:
with open(resp_file, "r", encoding="utf-8", errors="replace") as f:
raw_body = f.read()
except OSError as exc:
with open(out_file, "w", encoding="utf-8") as out:
out.write(f"FAIL\t无法读取 Cloudflare 响应文件 (HTTP {status}): {exc}")
sys.exit(0)
try:
response = json.loads(raw_body)
except json.JSONDecodeError:
snippet = raw_body.strip().splitlines()[0][:120] if raw_body.strip() else "<空响应>"
with open(out_file, "w", encoding="utf-8") as out:
out.write(f"FAIL\t无效 JSON 响应 (HTTP {status}): {snippet}")
sys.exit(0)
if not isinstance(response, dict):
with open(out_file, "w", encoding="utf-8") as out:
out.write(f"FAIL\t非字典 JSON 响应 (HTTP {status})")
sys.exit(0)
if 200 <= status < 300 and response.get("success") is True:
with open(out_file, "w", encoding="utf-8") as out:
out.write("OK\tCloudflare 精准缓存清理成功")
sys.exit(0)
err_items = []
errors = response.get("errors")
if isinstance(errors, list):
for err in errors:
if isinstance(err, dict):
code = err.get("code")
code_str = f"[{code}] " if code is not None else ""
msg = err.get("message", str(err))
err_items.append(f"{code_str}{msg}")
elif err:
err_items.append(str(err))
elif errors:
err_items.append(str(errors))
messages = response.get("messages")
if isinstance(messages, list):
for msg in messages:
if isinstance(msg, dict):
code = msg.get("code")
code_str = f"[{code}] " if code is not None else ""
text = msg.get("message", str(msg))
err_items.append(f"{code_str}{text}")
elif msg:
err_items.append(str(msg))
elif messages:
err_items.append(str(messages))
details = "; ".join(err_items) if err_items else "无详细错误信息"
with open(out_file, "w", encoding="utf-8") as out:
out.write(f"FAIL\tHTTP {status}: {details}")
PY
purge_code=""
purge_msg=""
IFS=$'\t' read -r purge_code purge_msg < "$PURGE_PARSE_RESULT" || true
if [[ -z "$purge_code" ]]; then
purge_code="FAIL"
purge_msg="结果解析异常"
fi
if [[ "$purge_code" == "OK" ]]; then
CF_PURGE_SUCCESS=1
echo "Cloudflare 精准缓存清理成功 (已清理 10 个指定 URL)"
else
CF_PURGE_SUCCESS=0
echo "警告:Cloudflare purge 失败 ($purge_msg)" >&2
echo "警告:进入显式 cache-bust fallback 模式,线上访问依靠各资源版本化 URL 命中最新产物(未声称 purge 成功)" >&2
fi
check_public_resource() {
local path="$1"
local expected_type="$2"
local local_file="${3:-}"
local body_file metadata status content_type mime local_hash remote_hash
PUBLIC_CHECK_COUNT=$((PUBLIC_CHECK_COUNT + 1))
body_file="$TMP_DIR/public-$PUBLIC_CHECK_COUNT"
if ! metadata=$(curl --location --silent --show-error --connect-timeout 10 --max-time 30 \
-o "$body_file" -w $'%{http_code}\n%{content_type}' "https://focus.102434.xyz$path"); then
echo "公网请求失败:$path" >&2
return 1
fi
status=${metadata%%$'\n'*}
content_type=${metadata#*$'\n'}
if [[ "$status" != 200 ]]; then
echo "公网状态码异常:$path 返回 $status" >&2
return 1
fi
mime=${content_type%%;*}
mime=${mime,,}
mime=${mime//[[:space:]]/}
case "$expected_type:$mime" in
html:text/html|svg:image/svg+xml|ico:image/x-icon|ico:image/vnd.microsoft.icon|ico:image/ico|ico:application/octet-stream|png:image/png|json:application/json|json:text/json|json:application/manifest+json)
;;
*)
echo "公网 Content-Type 异常:$path 返回 ${content_type:-<empty>}" >&2
return 1
;;
esac
if [[ "$expected_type" == "html" ]]; then
local html_vpaths=(
"/favicon.svg?v=$ICON_VERSION_SVG"
"/favicon.ico?v=$ICON_VERSION_ICO"
"/favicon-16x16.png?v=$ICON_VERSION_FAV16"
"/favicon-32x32.png?v=$ICON_VERSION_FAV32"
"/apple-touch-icon.png?v=$ICON_VERSION_APPLE"
"/manifest.json?v=$ICON_VERSION_MANIFEST"
)
for vpath in "${html_vpaths[@]}"; do
if ! grep -Fq "$vpath" "$body_file"; then
echo "公网 HTML 响应未包含版本化路径:$vpath ($path)" >&2
return 1
fi
done
fi
if [[ "$path" == /manifest.json* ]]; then
if ! grep -Fq "/pwa-192.png?v=$ICON_VERSION_PWA192" "$body_file"; then
echo "公网清单响应未包含版本化 PWA 图标路径:/pwa-192.png?v=$ICON_VERSION_PWA192" >&2
return 1
fi
if ! grep -Fq "/pwa-512.png?v=$ICON_VERSION_PWA512" "$body_file"; then
echo "公网清单响应未包含版本化 PWA 图标路径:/pwa-512.png?v=$ICON_VERSION_PWA512" >&2
return 1
fi
fi
if [[ -n "$local_file" ]]; then
local_hash=$(sha256sum -- "$local_file")
local_hash=${local_hash%% *}
if [[ "$path" =~ \?v=([0-9a-f]{12})$ ]]; then
local expected_v="${BASH_REMATCH[1]}"
if [[ "${local_hash:0:12}" != "$expected_v" ]]; then
echo "本地文件哈希与路径版本不匹配:$path (文件前12位 ${local_hash:0:12}, 路径 $expected_v)" >&2
return 1
fi
fi
remote_hash=$(sha256sum -- "$body_file")
remote_hash=${remote_hash%% *}
if [[ "$local_hash" != "$remote_hash" ]]; then
echo "公网文件与本地构建不一致:$path (本地 $local_hash, 远端 $remote_hash)" >&2
return 1
fi
fi
echo "已验证 $path ($status, $mime)"
}
public_fail_rollback() {
local failed_resource="$1"
echo "公网验收失败:$failed_resource" >&2
echo "==> 开始通过远端持锁协调器执行原子回滚..." >&2
local rollback_status="FAIL"
if [[ -n "${COORD_IN_FD_OPEN:-}" && -n "${COORD_OUT_FD_OPEN:-}" && -n "${COORD_PID:-}" ]]; then
cat <<ROLLBACK_EOF >&3
cd '$REMOTE_DIR'
# 注:线上当前已有 dist.previous;未来若是首次部署或无上一版历史,走明确的 no rollback 分支并保留当前版本
if [[ ! -e dist.previous && ! -L dist.previous ]]; then
echo "无 dist.previous 历史版本(首次部署或历史版本不存在),无法自动回滚,保留当前版本" >&2
echo "ROLLBACK_FAIL"
elif [[ -L dist.previous || ! -d dist.previous || ! -s dist.previous/index.html ]]; then
echo "历史版本 dist.previous 不合法(符号链接、非有效目录或缺少 index.html),无法安全回滚,保留当前版本" >&2
echo "ROLLBACK_FAIL"
elif [[ -L dist || ! -d dist || ! -s dist/index.html ]]; then
echo "线上目录 dist 为符号链接或缺少有效 index.html,无法安全进行双向原子交换回滚,保留两份目录" >&2
echo "ROLLBACK_FAIL"
else
coord_exchange() {
python3 -I -c '
import ctypes, os, sys
AT_FDCWD = -100
RENAME_EXCHANGE = 2
libc = ctypes.CDLL(None, use_errno=True)
try:
renameat2 = libc.renameat2
except AttributeError as exc:
sys.exit("libc does not provide renameat2")
renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
renameat2.restype = ctypes.c_int
if renameat2(AT_FDCWD, os.fsencode(sys.argv[1]), AT_FDCWD, os.fsencode(sys.argv[2]), RENAME_EXCHANGE) != 0:
err = ctypes.get_errno()
sys.exit(f"renameat2 error {err}: {os.strerror(err)}")
' "\$1" "\$2"
}
coord_origin_healthy() {
local resp
resp=\$(curl -fsS --connect-timeout 3 --max-time 10 -H 'Host: focus.102434.xyz' http://127.0.0.1/) || return 1
[[ "\$resp" == *'id="root"'* ]]
}
if ! coord_exchange dist dist.previous; then
echo "警告:远端回滚原子交换 renameat2(dist, dist.previous) 失败,保留两份目录" >&2
echo "ROLLBACK_FAIL"
elif ! coord_origin_healthy || [[ -L dist || ! -d dist || ! -s dist/index.html ]]; then
echo "警告:远端回滚后上一版源站健康检查失败,正在交换回部署新版本" >&2
if coord_exchange dist dist.previous; then
echo "已恢复部署新版本 dist,保留两份目录 (dist 与 dist.previous)" >&2
else
echo "严重警告:二次交换回新版本失败!两份目录均已保留,请人工核查 dist 与 dist.previous" >&2
fi
echo "ROLLBACK_FAIL"
else
echo "远端源站已成功回滚至上一版本 dist.previous,源站健康检查通过;新部署版本保留在 dist.previous" >&2
echo "ROLLBACK_OK"
fi
fi
ROLLBACK_EOF
local line
while true; do
if ! IFS= read -r -t 30 line <&4; then
echo "读取远端回滚输出超时或连接异常中断(30s 无响应)" >&2
rollback_status="TIMEOUT"
break
fi
if [[ "$line" == "ROLLBACK_OK" ]]; then
rollback_status="OK"
break
elif [[ "$line" == "ROLLBACK_FAIL" ]]; then
rollback_status="FAIL"
break
else
echo " [remote-rollback] $line"
fi
done
echo "exit 1" >&3 2>/dev/null || true
exec 3>&- 2>/dev/null || true
COORD_IN_FD_OPEN=""
else
echo "远端协调器未就绪或连接已关闭,无法执行回滚" >&2
fi
if [[ "$rollback_status" == "OK" ]]; then
echo "公网验证失败,已成功回滚远端源站至上一版本 (dist.previous)" >&2
else
echo "警告:公网验证失败,远端自动回滚未能成功(状态: $rollback_status),已保留现场目录供人工排查" >&2
fi
exit 1
}
PUBLIC_CHECK_COUNT=0
echo "==> 验证公网响应和图标内容"
if ! check_public_resource / html "$ROOT_DIR/dist/index.html"; then
public_fail_rollback "首页 /"
fi
if ! check_public_resource /index.html html "$ROOT_DIR/dist/index.html"; then
public_fail_rollback "/index.html"
fi
if ! check_public_resource "/favicon.svg?v=$ICON_VERSION_SVG" svg "$ROOT_DIR/dist/favicon.svg"; then
public_fail_rollback "favicon.svg"
fi
if ! check_public_resource "/favicon.ico?v=$ICON_VERSION_ICO" ico "$ROOT_DIR/dist/favicon.ico"; then
public_fail_rollback "favicon.ico"
fi
if ! check_public_resource "/favicon-16x16.png?v=$ICON_VERSION_FAV16" png "$ROOT_DIR/dist/favicon-16x16.png"; then
public_fail_rollback "favicon-16x16.png"
fi
if ! check_public_resource "/favicon-32x32.png?v=$ICON_VERSION_FAV32" png "$ROOT_DIR/dist/favicon-32x32.png"; then
public_fail_rollback "favicon-32x32.png"
fi
if ! check_public_resource "/apple-touch-icon.png?v=$ICON_VERSION_APPLE" png "$ROOT_DIR/dist/apple-touch-icon.png"; then
public_fail_rollback "apple-touch-icon.png"
fi
if ! check_public_resource "/manifest.json?v=$ICON_VERSION_MANIFEST" json "$ROOT_DIR/dist/manifest.json"; then
public_fail_rollback "manifest.json"
fi
if ! check_public_resource "/pwa-192.png?v=$ICON_VERSION_PWA192" png "$ROOT_DIR/dist/pwa-192.png"; then
public_fail_rollback "pwa-192.png"
fi
if ! check_public_resource "/pwa-512.png?v=$ICON_VERSION_PWA512" png "$ROOT_DIR/dist/pwa-512.png"; then
public_fail_rollback "pwa-512.png"
fi
SWITCH_COMPLETED=1
# 5. 全部验证通过后,释放远端部署锁并有界等待协调器退出
echo "==> 释放远端部署锁"
echo "exit 0" >&3
exec 3>&-
COORD_IN_FD_OPEN=""
coord_exited=0
for (( i = 0; i < 100; i++ )); do
if ! kill -0 "$COORD_PID" 2>/dev/null; then
coord_exited=1
break
fi
sleep 0.1
done
if [[ "$coord_exited" -eq 1 ]]; then
if ! wait "$COORD_PID"; then
echo "远端协调器退出异常" >&2
COORD_PID=""
exec 4<&- 2>/dev/null || true
COORD_OUT_FD_OPEN=""
exit 1
fi
COORD_PID=""
else
echo "远端协调器在 exit 0 后未能在 10s 内正常退出,强制终止" >&2
kill "$COORD_PID" 2>/dev/null || true
for (( i = 0; i < 20; i++ )); do
kill -0 "$COORD_PID" 2>/dev/null || break
sleep 0.1
done
kill -9 "$COORD_PID" 2>/dev/null || true
wait "$COORD_PID" 2>/dev/null || true
COORD_PID=""
exec 4<&- 2>/dev/null || true
COORD_OUT_FD_OPEN=""
exit 1
fi
exec 4<&- 2>/dev/null || true
COORD_OUT_FD_OPEN=""
if [[ "$CF_PURGE_SUCCESS" -eq 1 ]]; then
echo "部署完成:https://focus.102434.xyz"
else
echo "源站部署成功,Cloudflare purge失败,图标通过版本化URL更新;旧缓存记录未主动清除"
echo "部署完成:https://focus.102434.xyz"
fi

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"deploy": "bash deploy.sh",
"lint": "oxlint",
"preview": "vite preview"
},