From 7aebe96bb38dda1759f4af8d800ee39c6322d685 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Fri, 18 Sep 2026 02:13:19 +0800 Subject: [PATCH] feat(deploy): T2 frontend-layer assertions after [3] (insert-only) 3 independent hard gates: (1) per-file md5 reconciliation dist/assets vs webroot/assets, (2) entry chunk from webroot/index.html must exist in dist with same md5, (3) every chunk referenced by the main bundle must return HTTP 200 from SITE_URL. Env overrides FRONTEND_DIST_DIR / CMA_WEBROOT / SITE_URL for offline stub rehearsal (no build). No push to main. --- deploy.sh | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/deploy.sh b/deploy.sh index 3bfcf76b..c59c2262 100755 --- a/deploy.sh +++ b/deploy.sh @@ -48,6 +48,103 @@ rm -rf /var/www/cma/assets/ cp -r dist/* /var/www/cma/ echo " ✓ 部署完成" +# 3b. 前端层一致性断言(T2, 2026-09-18):部署链静默替换门 +# 背景:[3] 只有 rm -rf + cp,缓存层吐旧 index、或 index 指向已被删的旧 chunk 时,现象是 +# 「首页 200、点进页面 404/白屏」,而其后的冒烟 5 项全为后端项(127.0.0.1:8010)→ 整轮部署全绿。 +# 三条断言各自独立输出,任一不一致即 exit 1。离线演练只覆盖下面三个变量,无需 build。 +# >>> cma-frontend-assert(最小可抽取块:scripts/tests 按行首标记 awk 抽取后原样执行,改结构请沿用标记) +FRONTEND_DIST_DIR="${FRONTEND_DIST_DIR:-$SCRIPT_DIR/frontend/dist}" +CMA_WEBROOT="${CMA_WEBROOT:-/var/www/cma}" +SITE_URL="${SITE_URL:-https://cma.sxbh.ltd}" +echo "[3b/5] 前端层一致性断言(dist ↔ webroot ↔ 引用链)..." +FRONTEND_ASSERT_FAIL=0 + +# ① 逐文件 md5 对账:dist/assets 与 webroot/assets 文件集合一致(无多、无少)且同名文件 md5 相同 +DIST_ASSETS="$FRONTEND_DIST_DIR/assets" +WEB_ASSETS="$CMA_WEBROOT/assets" +DIST_FILES="$(ls -1 "$DIST_ASSETS" 2>/dev/null | sort)" +WEB_FILES="$(ls -1 "$WEB_ASSETS" 2>/dev/null | sort)" +MISSING_FILES="$(comm -23 <(printf '%s\n' "$DIST_FILES") <(printf '%s\n' "$WEB_FILES"))" +EXTRA_FILES="$(comm -13 <(printf '%s\n' "$DIST_FILES") <(printf '%s\n' "$WEB_FILES"))" +MD5_DIFF="" +while IFS= read -r F; do + [ -n "$F" ] || continue + D_MD5="$(md5sum "$DIST_ASSETS/$F" 2>/dev/null | awk '{print $1}')" + W_MD5="$(md5sum "$WEB_ASSETS/$F" 2>/dev/null | awk '{print $1}')" + [ "$D_MD5" = "$W_MD5" ] || MD5_DIFF="${MD5_DIFF}${MD5_DIFF:+, }$F(dist=$D_MD5 web=$W_MD5)" +done <<< "$DIST_FILES" +if [ -n "$MISSING_FILES" ] || [ -n "$EXTRA_FILES" ] || [ -n "$MD5_DIFF" ]; then + echo " ❌ 断言① 逐文件 md5 对账失败(dist=$DIST_ASSETS)" + [ -z "$MISSING_FILES" ] || echo " webroot 缺失: $(printf '%s' "$MISSING_FILES" | tr '\n' ' ')" + [ -z "$EXTRA_FILES" ] || echo " webroot 多出: $(printf '%s' "$EXTRA_FILES" | tr '\n' ' ')" + [ -z "$MD5_DIFF" ] || echo " md5 不一致: $MD5_DIFF" + FRONTEND_ASSERT_FAIL=1 +else + echo " ✅ 断言① 逐文件 md5 对账通过($(printf '%s\n' "$DIST_FILES" | awk 'NF{n++} END{print n+0}') 个文件全部一致)" +fi + +# ② 主包一致:webroot/index.html 解析出的入口 chunk 必须在 dist 存在且 md5 相同 +INDEX_HTML="$CMA_WEBROOT/index.html" +MAIN_BUNDLE="$(grep -o '/assets/index-[A-Za-z0-9._-]*\.js' "$INDEX_HTML" 2>/dev/null | head -n 1)" +if [ -z "$MAIN_BUNDLE" ]; then + echo " ❌ 断言② 主包一致失败: 未能从 $INDEX_HTML 解析出入口 chunk(/assets/index-*.js)" + FRONTEND_ASSERT_FAIL=1 +elif [ ! -f "$FRONTEND_DIST_DIR$MAIN_BUNDLE" ]; then + echo " ❌ 断言② 主包一致失败: index.html 引用的 $MAIN_BUNDLE 在 dist 不存在($FRONTEND_DIST_DIR)" + FRONTEND_ASSERT_FAIL=1 +else + MAIN_D_MD5="$(md5sum "$FRONTEND_DIST_DIR$MAIN_BUNDLE" 2>/dev/null | awk '{print $1}')" + MAIN_W_MD5="$(md5sum "$CMA_WEBROOT$MAIN_BUNDLE" 2>/dev/null | awk '{print $1}')" + if [ -n "$MAIN_D_MD5" ] && [ "$MAIN_D_MD5" = "$MAIN_W_MD5" ]; then + echo " ✅ 断言② 主包一致通过($MAIN_BUNDLE dist=web=$MAIN_D_MD5)" + else + echo " ❌ 断言② 主包一致失败: $MAIN_BUNDLE dist=$MAIN_D_MD5 web=$MAIN_W_MD5" + FRONTEND_ASSERT_FAIL=1 + fi +fi + +# ③ 引用链可 200:主包内引用的懒加载 chunk 逐个请求 $SITE_URL,必须全部 HTTP 200(无静默跳过分支) +# 字面量形态取并集:现网 Vite 产物实测为相对形态 from"./Login-CCqwmqG_.js",另有 "/assets/*.js" 形态; +# 两者都归一到 /assets/ 再请求。解析不到任何引用 → 显式判失败(不得静默放行)。 +MAIN_JS_FILE="$CMA_WEBROOT$MAIN_BUNDLE" +CHUNKS="$(grep -o -E '(\./|/assets/)[A-Za-z0-9._-]+\.js' "$MAIN_JS_FILE" 2>/dev/null | sed -E 's#^\./#/assets/#' | sort -u)" +CHUNK_TOTAL="$(printf '%s\n' "$CHUNKS" | awk 'NF{n++} END{print n+0}')" +if [ ! -f "$MAIN_JS_FILE" ]; then + echo " ❌ 断言③ 引用链可 200 失败: 主包在 webroot 不存在($MAIN_JS_FILE)" + FRONTEND_ASSERT_FAIL=1 +elif [ "$CHUNK_TOTAL" -eq 0 ]; then + echo " ❌ 断言③ 引用链可 200 失败: 主包 $MAIN_BUNDLE 内未解析到任何 chunk 引用(./*.js 或 /assets/*.js)" + FRONTEND_ASSERT_FAIL=1 +else + CHUNK_OK=0 + CHUNK_BAD="" + while IFS= read -r C; do + [ -n "$C" ] || continue + C_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$SITE_URL$C" 2>/dev/null || echo 000)" + if [ "$C_CODE" = "200" ]; then + CHUNK_OK=$((CHUNK_OK + 1)) + else + CHUNK_BAD="${CHUNK_BAD}${CHUNK_BAD:+, }$C(HTTP=$C_CODE)" + fi + done <<< "$CHUNKS" + if [ -n "$CHUNK_BAD" ]; then + echo " ❌ 断言③ 引用链可 200 失败($SITE_URL): $CHUNK_BAD" + FRONTEND_ASSERT_FAIL=1 + else + echo " ✅ 断言③ 引用链可 200 通过($CHUNK_OK/$CHUNK_TOTAL chunk @ $SITE_URL)" + fi +fi + +# 三条均为硬门:任一不一致即中止部署(nginx 直接服务静态文件,无需 reload) +if [ "$FRONTEND_ASSERT_FAIL" -ne 0 ]; then + echo "" + echo "🚨 前端层一致性断言未通过 → 中止部署(webroot 可能停留在旧包 / 半包)" + echo " dist=$FRONTEND_DIST_DIR webroot=$CMA_WEBROOT site=$SITE_URL" + exit 1 +fi +echo " ✓ 前端层一致性断言通过(① 逐文件 md5 ② 主包 ③ 引用链 200)" +# <<< cma-frontend-assert + # 4. 更新后端依赖(失败不阻断 — 依赖已在系统Python中,避免PEP668中断部署) echo "[4/5] 更新后端依赖..." cd "$SCRIPT_DIR/backend"